Fractal Softworks Forum

Starsector => General Discussion => Topic started by: CapnHector on October 23, 2022, 12:11:27 PM

Title: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on October 23, 2022, 12:11:27 PM
Optimizing the Conquest: a Mathematical Model of Space Combat
Authors: CapnHector, intrinsic_parity, Thaago, Vanshilar*

Introduction
The Conquest-class battlecruiser is a ship designed for a combination of speed and firepower, with a specific goal of winning engagements by rapid deployment of overwhelming firepower while sacrificing defense. Therefore, correct weapon choice is paramount to success. Simulations show that the Conquest is an effective ship for combating high-level threats [1], but the optimal weapon layout remains unknown. While simulation can demonstrate the effectiveness of a specific layout [1], it is unrealistic to simulate all possible combinations of weapons against all the diverse threats that spacefaring fleets face in the Sector. Furthermore, human bias, such as for symmetry, and excessive focus on particular threats may guide unsystematic simulation experimentation. Thus, effective combinations may exist that are so far untested. To provide an unbiased analysis of weapon effectiveness and layout effectiveness for the Conquest, we developed a mathematical model of space combat, calibrating against simulation data, and used this model to forecast the time to kill (TTK) of  weapons and combinations of weapons when mounted on the Conquest-class battlecruiser. To our knowledge, this is the first complete mathematical model of spaceborne weapons and their effects in the Persean sector.

Methods
Weapons and ships
The weapons studied were the Squall MLRS, Locust SRM, Hurricane MIRV, Harpoon MRM, Sabot SRM, Gauss Cannon, Mark IX Autocannon, Mjolnir Cannon, Hephaestus Assault Gun, Storm Needler, Arbalest Autocannon, Heavy Mauler, Hypervelocity Driver, Heavy Needler, and Heavy Mortar. A Conquest layout was considered to include 2 choices of large missile, 2 choices of medium missile, 2 choices of large gun, and 2 choices of medium gun. The target ships were Glimmer, Brawler (LP), Vanguard, Tempest, Medusa, Hammerhead, Enforcer, Dominator, Fulgent, Brilliant, Radiant, Onslaught, Aurora, Paragon and Champion-class vessels. Damper Fields and Fortress Shields were not modeled. For the weapons, damage per shot, damage type, firing pattern over time, accuracy, and recoil (accuracy loss per shot) data were used. For target ships, the data used were ship width and length, number of armor cells, baseline flux dissipation capacity, maximum flux capacity, shield effectiveness, hull integrity score, and armor score. All variables were imported from Starsector version 0.95.1a files, except weapon firing pattern was constructed based on firing duration and delay data, and armor cell number was calculated based on a previously published and verified algorithm [2].

Mathematical model
Mathematical model
For the mathematical model, the following assumptions were made:
1. The target vessel is in the center of the weapon's field of fire, but with an error term that follows the normal distribution. We define f=SD(error)/range(px). The target vessel is facing the weapons.
2. Range to target is 1000 units.
3. All weapons target the center of the target vessel.
4. Weapons firing angle is random with an angle range determined by the weapon's known maximum angle range. The weapons are mounted on turrets, not hardpoints.
5. The weapons fire continuously for the testing period.
6. The target vessel has no flux capacitors and only enough flux vents to offset shield upkeep cost.
7. The target vessel has no officer or hullmods.
8. The target vessel will raise shields to block any shots that it can block without overloading. If it were overloaded by blocking the next shot, the target vessel will lower shields instead.

A single shot's firing angle is modeled as drawn from a uniform distribution [-maxangle/2, maxangle/2]. The target vessel's visual angle is calculated based on ship width and engagement range, ignoring curvature. These are used to compute the expected hit location in pixels, to which is added a random error described by a normal distribution such that mu=0 and SD=f*1000 pixels. The result is used to determine whether the target vessel was hit and if so, which armor cell was hit, based on the pixel ranges for the target vessel's hull width and each armor cell's width. The hit is considered to be immediate with no travel time.

In order to create a probabilistic model of armor damage permitting cell by cell operations, ship armor is described as a [5, armorcells+4] matrix which is damaged according to cell by cell calculations with each hit on the central [1,armorcells] cells damaging the [5,5] matrix around it, in an inverse of the Starsector armor pooling function, such that damage is spread over a [5,5] matrix with corner cells taking 0 damage, the central cells and adjacent cells taking 1/15 damage, and their neighbor cells taking 1/30 damage, to a total of 100% of damage spread over the hit armor matrix, with central cells absorbing double the damage of peripheral cells. By contrast, Starsector is thought to pool armor strength from the cells, with peripheral cells contributing half the armor contribution of the central cells. While formal proof that the method is exactly equivalent to Starsector's handling of armor has proven elusive, we have previously noted that, based on experimentation, any error is expected to be small. Elementary algebra shows that the two methods of calculating damage are at least equivalent when it is not the case that armor %u2248 5% of starting armor. For an illustration of armor calculations, see Figure 1.

Weapon hits were modeled as the expected value of the hit (i.e. an expected value wave over the armor), an approximation previously found to be very close to the true value found by simulating individual shots [3]. Shot damage could only be reduced by a maximum of 85% by armor. When armor was lower than 5% of maximum, armor was counted as 5% of maximum for purposes of determining damage reduction. Damage was first applied to shields, then to armor if shields were not used to block, and then the excess to hull if armor could not absorb the entirety of the hit strength. Damage modifiers were used as in Starsector, that is, all weapon types contribute their full hit strength to hull damage, but adjusted by damage type for shield and armor damage. The mathematical model in its entirety is included in the appendix.

The factor f was determined to be 0.05 based on previously published [4] empirical data (Figure 2), corresponding to an error in assumption 1 such that 95% of the time the enemy ship is within 100 pixels of the center of the weapon's firing arc.

The maximum permitted combat time was 500 seconds, which was exceeded by some combinations with Hurricane-Hurricane or Hellbore-Hellbore vs. Paragon or Radiant. These outliers were excluded from the analysis.
[close]
Statistical analysis
Statistical analysis
The distribution of hits for each weapon, ship and timepoint was generated based on the mathematical model using 100 000 samples. Because the model was deterministic, it was run once for every studied combination of weapons for every ship. The distribution for each weapon, ship and timepoint combination was calculated once, then re-used based on a lookup table. The total number of combat models run was 16 times the number of possible unique layouts, that is, 16 x 7938 = 127 008 combats. For each combat model, the time to kill and weapons used were recorded. Time to kill was averaged across all ships for each weapon separately, for pairs of weapons from each category, and for combinations of large missiles and large guns. The results represent the reduction / increase in time to kill that results from using a specific combination, all other equipment being equal, on average, with flux and range combinations ignored. All analyses were performed using RStudio 2022.07.1+554. While analysis of variance was not performed, the sample size was at least 48 combats for each reported result. Further sub-analyses were performed for capital ships and Remnants as meriting special consideration.
[close]

Figure 1. Expected distribution of damage from a Hephaestus Assault Gun firing on a Dominator-class Heavy Cruiser
(https://i.postimg.cc/Njz80K4Q/figure2hephaestus.png) (https://postimages.org/)

Figure 2. Effect of positional error (f-factor) on TTK
(https://i.ibb.co/m6X7PYJ/fudgefactor.png) (https://ibb.co/SRdpZ1N)

Results
The results of the main analysis including pairs and combinations of weapons are presented in Table 1. Scores for individual weapons are presented in Table 2. Mean time to kill each type of ship in the second analysis which included medium guns is presented in Table 4. Preliminary analyses (reported separately) have shown that the model yields vastly different results if weapon accuracy is not simulated. Finally, Figure 3 demonstrates different strategies against the hardiest ship tested, the Radiant-class drone battleship.

Table 1. Combinations of weapons

"Large missiles"   "Avg. time to kill"   "TTK speed score"
"Squall Locust"   21.8   "4.0%"
"Locust Locust"   22.1   "2.7%"
"Squall Hurricane"   22.5   "0.9%"
"Squall Squall"   22.9   "-0.8%"
"Locust Hurricane"   23   "-1.3%"
"Hurricane Hurricane"   23.9   "-5.0%"

"Medium missiles"   "Avg. time to kill"   "TTK speed score"
"Harpoon Harpoon"   20.1   "12.8%"
"Sabot Harpoon"   22.1   "2.6%"
"Sabot Sabot"   25.8   "-12.2%"

"Large guns"   "Avg. time to kill"   "TTK speed score"
"Mjolnir Mjolnir"   18.8   "20.7%"
"Gauss Mjolnir"   19.4   "17.1%"
"Gauss Gauss"   20   "13.2%"
"Hephaestus Mjolnir"   20.6   "10.3%"
"Mjolnir Storm Needler"   21   "8.3%"
"Mark IX Mjolnir"   21.1   "7.7%"
"Gauss Hephaestus"   21.1   "7.6%"
"Hellbore Mjolnir"   21.8   "4.0%"
"Gauss Storm Needler"   21.9   "3.5%"
"Gauss Mark IX"   22   "3.1%"
"Hephaestus Hephaestus"   22.7   "0.1%"
"Hephaestus Storm Needler"   22.9   "-0.8%"
"Gauss Hellbore"   23.1   "-1.8%"
"Hephaestus Mark IX"   23.2   "-2.3%"
"Storm Needler Storm Needler"   23.7   "-4.1%"
"Mark IX Storm Needler"   24.1   "-6.0%"
"Hellbore Hephaestus"   24.2   "-6.4%"
"Mark IX Mark IX"   25   "-9.3%"
"Hellbore Storm Needler"   25.1   "-9.7%"
"Hellbore Mark IX"   26.1   "-12.9%"
"Hellbore Hellbore"   28.7   "-21.0%"

"Medium guns"   "Avg. time to kill"   "TTK speed score"
"Arbalest Heavy Needler"   21.6   "5.2%"
"Heavy Mortar Heavy Needler"   21.8   "3.9%"
"Heavy Autocannon Heavy Needler"   22   "3.3%"
"Arbalest Arbalest"   22   "3.1%"
"Arbalest Heavy Autocannon"   22.1   "2.8%"
"Heavy Needler Heavy Needler"   22.1   "2.5%"
"Arbalest Heavy Mortar"   22.1   "2.5%"
"Heavy Autocannon Heavy Mortar"   22.2   "2.1%"
"Heavy Autocannon Heavy Autocannon"   22.3   "1.8%"
"Arbalest Heavy Mauler"   22.3   "1.7%"
"Heavy Autocannon Heavy Mauler"   22.6   "0.4%"
"Arbalest Hypervelocity Driver"   22.7   "0.0%"
"Heavy Mauler Heavy Mortar"   22.8   "-0.6%"
"Heavy Mortar Hypervelocity Driver"   23   "-1.2%"
"Heavy Mauler Heavy Needler"   23   "-1.2%"
"Heavy Autocannon Hypervelocity Driver"   23   "-1.3%"
"Heavy Needler Hypervelocity Driver"   23   "-1.4%"
"Heavy Mortar Heavy Mortar"   23.1   "-1.6%"
"Heavy Mauler Hypervelocity Driver"   24.2   "-6.3%"
"Hypervelocity Driver Hypervelocity Driver"   24.3   "-6.5%"
"Heavy Mauler Heavy Mauler"   24.4   "-7.0%"

Large missiles x Large guns x Medium guns table is too large to post on the forum, available here: https://pastebin.com/Yvrftsw1
[close]

Table 2. Single weapon effectiveness.

"Large missile"   "Avg. time to kill"   "TTK speed score"
"Squall"   22.4   "2.5%"
"Locust"   22.5   "1.8%"
"Hurricane"   23.9   "-4.0%"

"Medium missiles"   "Avg. time to kill"   "TTK speed score"
"Harpoon"   21.1   "11.2%"
"Sabot"   25.8   "-9.2%"

"Large gun"   "Avg. time to kill"   "TTK speed score"
"Mjolnir"   20.5   "12.1%"
"Gauss"   21.3   "8.3%"
"Hephaestus"   22.8   "1.1%"
"Storm Needler"   23.7   "-2.7%"
"Mark IX"   23.9   "-3.7%"
"Hellbore"   26   "-11.5%"

"Medium gun"   "Avg. time to kill"   "TTK speed score"
"Arbalest"   22   "2.8%"
"Heavy Autocannon"   22.2   "2.0%"
"Heavy Needler"   22.3   "1.3%"
"Heavy Mortar"   22.5   "0.5%"
"Hypervelocity Driver"   23.3   "-3.0%"
"Heavy Mauler"   23.4   "-3.3%"
[close]

Table 3. Target ship durability.

"Ship"   "Avg. time to kill"   "Avg. TTK speed score"
"glimmer"   8.8   "158.1%"
"tempest"   9.2   "147.9%"
"brawlerlp"   10.2   "121.4%"
"medusa"   10.8   "109.1%"
"vanguard"   12.1   "87.6%"
"fulgent"   13   "74.7%"
"hammerhead"   14.6   "55.0%"
"enforcer"   16   "41.4%"
"brilliant"   24.1   "-6.0%"
"aurora"   25.1   "-9.5%"
"champion"   25.4   "-10.7%"
"conquest"   27   "-16.1%"
"dominator"   32.2   "-29.6%"
"onslaught"   35.1   "-35.4%"
"paragon"   47.5   "-52.3%"
"radiant"   51.6   "-56.1%"

[close]

Discussion
To our knowledge, this was the first complete mathematical model describing weapon damage in space combat in the Persean sector. Our results demonstrate that it is critical to consider all factors in the simulation. For example, our preliminary results from models that did not consider the different shot distributions and accuracy of different weapons diverged markedly from those produced by the complete model. A model without a positional error factor (f-factor) also significantly underestimated TTK compared to simulations, proving the need to include a measure of uncertainty in mathematical models of space combat.

Our results with regard to target vessel durability display linearity: ships are approximately as durable as can be expected from their deployment costs. Generally, older technology stands better to the rigors of space combat. An interesting special case, however, is the Conquest-class battlecruiser itself, which clearly has the defensive attributes of a cruiser, being in line with the defensive attributes of the Aurora- and Champion-class vessels. Another outlier is the Dominator-class heavy cruiser, which was found to have defensive attributes on par with capital ships.

The weapons of the Sector are surprisingly balanced, with no clearly dominating choices among the categories of single weapons. While certain weapons such as the Mjolnir appear to be generally advantageous in TTK, this comes at the cost of flux efficiency and / or range. The exception to this general rule is the category of medium missiles, where Harpoon MRMs should be the preferred choice, as they have a longer range, equal ammunition, and result in quicker times to kill than Sabot SRMs or combinations including Sabot SRMs.

It is interesting to note that among medium guns, older Collapse-era technology has a surprising advantage, as the Arbalest resulted in slightly faster kills than newer weapons, despite accuracy problems that were included in the model. However, we note that this comes at a range disadvantage that may not be justified by the slightly faster TTK.

The picture is significantly more nuanced when considering combinations of weapons, where carefully chosen combinations yield significantly larger effects than those of single weapons. It is especially interesting to note that while the Locust SRM is by itself inferior to the Squall MLRS, the combination of one Squall MLRS and one Locust SRM was the strongest combination of large missiles tested, although it should be noted that the ensuing range limitation likely means this combination might be inferior to two Squall MLRS in a fleet setting. Even more important than careful choice of weapon combinations, however, is avoiding combinations with limited potential against a variety of targets. For example, a Hellbore Hellbore combination was associated with -21.0% speed to achieve kill, and was notably one of the only combinations that could not kill all targets.

Overall, our results highlight the potential in systematic study and simulation of space combat, and the importance of commanders' personal skill in designing compatible weapon layouts holistically, considering the excellent balance among single weapons.

Author statement
CapnHector created the mathematical models and wrote the report. Vanshilar, intrinsic_parity and Thaago made significant contributions to developing the model.

References
[1] Vanshilar 2022, https://fractalsoftworks.com/forum/index.php?topic=25459.msg379575#msg379575
[2] Vanshilar 2022, https://fractalsoftworks.com/forum/index.php?topic=25459.msg379452#msg379452
[3] Vanshilar 2022, https://fractalsoftworks.com/forum/index.php?topic=25459.msg379708#msg379708
[4] Vanshilar 2022, https://fractalsoftworks.com/forum/index.php?topic=25459.msg379103#msg379103

Note: tables updated on 22-11-04. Errata in: https://fractalsoftworks.com/forum/index.php?topic=25536.msg380632#msg380632
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on October 23, 2022, 12:11:37 PM
Appendix 1: R scripts: attachment
Original data available upon request

Figure 3 and tables 4 and 5 to be added.

Table 4. Vs. Remnant ships only

"Large gun"   "Avg. time to kill"   "TTK speed score"
"Mjolnir"   22.2   "10.9%"
"Gauss"   22.9   "7.5%"
"Storm Needler"   23.5   "4.9%"
"Hephaestus"   24.5   "0.6%"
"Mark IX"   25.7   "-4.1%"
"Hellbore"   29   "-15.0%"


"Large guns"   "Avg. time to kill"   "TTK speed score"
"Mjolnir Mjolnir"   20.7   "18.2%"
"Gauss Mjolnir"   20.9   "17.4%"
"Gauss Gauss"   21.6   "13.1%"
"Mjolnir Storm Needler"   21.8   "12.4%"
"Mark IX Mjolnir"   22.6   "8.3%"
"Hephaestus Mjolnir"   22.7   "7.7%"
"Gauss Storm Needler"   22.9   "6.9%"
"Gauss Hephaestus"   23   "6.3%"
"Storm Needler Storm Needler"   23.5   "4.3%"
"Hellbore Mjolnir"   23.6   "3.7%"
"Gauss Mark IX"   23.7   "3.3%"
"Hephaestus Storm Needler"   23.7   "3.3%"
"Mark IX Storm Needler"   24.8   "-1.2%"
"Hephaestus Mark IX"   25.2   "-3.0%"
"Hephaestus Hephaestus"   25.3   "-3.1%"
"Gauss Hellbore"   25.3   "-3.4%"
"Hellbore Storm Needler"   25.6   "-4.4%"
"Hellbore Hephaestus"   26.5   "-7.4%"
"Mark IX Mark IX"   27.3   "-10.4%"
"Hellbore Mark IX"   28.5   "-14.1%"
"Hellbore Hellbore"   34.8   "-29.7%"

"Large missile"   "Avg. time to kill"   "TTK speed score"
"Squall"   23.5   "6.3%"
"Locust"   24.8   "0.7%"
"Hurricane"   26.6   "-6.2%"

"Large missiles"   "Avg. time to kill"   "TTK speed score"
"Squall Locust"   22.9   "6.7%"
"Squall Squall"   23.5   "4.4%"
"Squall Hurricane"   24.2   "1.2%"
"Locust Locust"   24.5   "-0.1%"
"Locust Hurricane"   25.2   "-2.7%"
"Hurricane Hurricane"   26.6   "-8.1%"

"Medium missile"   "Avg. time to kill"   "TTK speed score"
"Harpoon"   23.2   "8.3%"
"Sabot"   27.1   "-7.1%"

"Medium gun"   "Avg. time to kill"   "TTK speed score"
"Arbalest"   23.5   "3.8%"
"Heavy Autocannon"   23.5   "3.6%"
"Heavy Mortar"   23.9   "1.8%"
"Heavy Needler"   24.1   "1.1%"
"Hypervelocity Driver"   25.3   "-3.5%"
"Heavy Mauler"   25.9   "-5.9%"

"Medium guns"   "Avg. time to kill"   "TTK speed score"
"Arbalest Heavy Needler"   22.9   "6.7%"
"Heavy Mortar Heavy Needler"   23.1   "5.9%"
"Heavy Autocannon Heavy Mortar"   23.4   "4.8%"
"Arbalest Heavy Mortar"   23.4   "4.5%"
"Heavy Autocannon Heavy Needler"   23.4   "4.5%"
"Arbalest Heavy Autocannon"   23.4   "4.5%"
"Arbalest Arbalest"   23.5   "4.2%"
"Heavy Autocannon Heavy Autocannon"   23.6   "3.7%"
"Arbalest Heavy Mauler"   24   "2.1%"
"Heavy Needler Heavy Needler"   24   "2.1%"
"Arbalest Hypervelocity Driver"   24.1   "1.4%"
"Heavy Autocannon Heavy Mauler"   24.2   "1.0%"
"Heavy Autocannon Hypervelocity Driver"   24.4   "0.2%"
"Heavy Mortar Hypervelocity Driver"   24.5   "0.1%"
"Heavy Mauler Heavy Mortar"   24.6   "-0.3%"
"Heavy Mortar Heavy Mortar"   24.7   "-1.0%"
"Heavy Needler Hypervelocity Driver"   25   "-2.2%"
"Heavy Mauler Heavy Needler"   25.2   "-2.7%"
"Heavy Mauler Hypervelocity Driver"   27.1   "-9.5%"
"Hypervelocity Driver Hypervelocity Driver"   27.2   "-10.0%"
"Heavy Mauler Heavy Mauler"   28.3   "-13.6%"

Large missiles x Large guns x Medium guns table at https://pastebin.com/Pwp2ap1u

[close]

This is a continuation of the discussion we had in the Conquest appreciation thread, see https://fractalsoftworks.com/forum/index.php?topic=25459.75 . But I felt it had expanded enough that it warranted its own topic.

Please let me know if you do not wish to be included as an author.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on October 23, 2022, 12:11:47 PM
Reserved, possibly for further analysis.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on October 23, 2022, 01:06:53 PM
This is amazing work, and reaffirms my love of the harpoon :D. Though the lack of travel time does make the harpoon real performance have a decent size asterisk on top. Its interesting that the budget medium ballistics do so well, but otoh this is in the absence of range and both of them have their strong points (DPS and decent HE hit size for mortar, kinetic damage type for anti-shield and high hit size for anti-hull for arbalest).

I will admit I find it puzzling that the Dominator ranked tougher than the Onslaught! Given that the Onslaught has more of all defenses this is weird to me, but is its larger size making it up given the strong role that shot dispersion plays in the model?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Tempest on October 23, 2022, 02:03:34 PM
This is no longer a video game  :D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on October 23, 2022, 09:47:07 PM
This is amazing work, and reaffirms my love of the harpoon :D. Though the lack of travel time does make the harpoon real performance have a decent size asterisk on top. Its interesting that the budget medium ballistics do so well, but otoh this is in the absence of range and both of them have their strong points (DPS and decent HE hit size for mortar, kinetic damage type for anti-shield and high hit size for anti-hull for arbalest).

I will admit I find it puzzling that the Dominator ranked tougher than the Onslaught! Given that the Onslaught has more of all defenses this is weird to me, but is its larger size making it up given the strong role that shot dispersion plays in the model?

The exact data used for the ships were
Spoiler

#ship, hullhp, shieldregen, shieldmax, startingarmor, widthinpixels, armorcells, name
glimmer <- c(1500, 250/0.6, 2500/0.6, 200, 78, 5, "glimmer")
brawlerlp <- c(2000, 500/0.8, 3000/0.8, 450,110,floor(110/15), "brawlerlp")
vanguard <- c(3000, 150, 2000, 600, 104, floor(104/15),"vanguard")
tempest <- c(1250, 225/0.6, 2500/0.6, 200,64,floor(64/15), "tempest")
medusa <- c(3000,400/0.6,6000/0.6,300,134,floor(134/15), "medusa")
hammerhead <- c(5000,250/0.8,4200/0.8,500,108,floor(108/16.4), "hammerhead")
enforcer <- c(4000,200,4000,900,136,floor(136/15), "enforcer")
dominator <- c(14000, 500, 10000, 1500, 180, 12, "dominator")
fulgent <- c(5000,300/0.6,5000/0.6,450, 160, floor(160/15), "fulgent")
brilliant <- c(8000,600/0.6,10000/0.6,900,160,floor(160/20),"brilliant")
radiant <- c(20000,1500/0.6,25000/0.6,1500,316,floor(316/30),"radiant")
onslaught <- c(20000,600,17000,1750,288,floor(288/30),"onslaught")
aurora <- c(8000,800/0.8,11000/0.8,800,128,floor(128/28), "aurora")
paragon <- c(18000,1250/0.6,25000/0.6,1500,330,floor(330/30),"paragon")
conquest <- c(12000,1200/1.4,20000/1.4,1200,190,floor(190/30),"conquest")
champion <- c(10000,550/0.8,10000/0.8,1250, 180,floor(180/24),"champion")
[close]

On paper, the Dominator does have worse defenses. There are two things that likely contribute to the Dominator's durability: 1) the Dominator is harder to hit, despite having comparable defenses and 2) the Dominator has more dense armor. The exact figure was calculated by Vanshilar in https://fractalsoftworks.com/forum/index.php?topic=25459.msg379452#msg379452 . Dominators have 5.56 armor per pixel, while Onslaughts have 3.89 armor per pixel. Now the Onslaught has slightly more armor but it is spread over 9 horizontal cells rather than 12 and the cells are wider. So the Onslaught's individual armor cells are likely penetrated quicker.

There is another interesting effect in that it is not a matter of the shortest times to kill being shorter for the Onslaught. Rather it is that poor combos (e.g. Squall Squall Hellbore Hellbore Sabot Sabot Arbalest Arbalest) have more trouble finishing off the Dominator. Looking at the graphs, this seems to be caused by more damage leaking through to the hull of the Onslaught, as predicted by the theory above, as the Onslaught's armor stays higher despite taking more hull damage.

(https://i.ibb.co/NtJxWQ7/image.png) (https://ibb.co/mb2Jh1S)

I do notice that accidentally the Dominator was 180 px wide rather than 220 px wide for the model in the report (it was the first ship I programmed in and I accidentally used the sprite height rather than width, which I noticed later but apparently forgot to save the change into the script running all the models - I paid attention to this when programming the rest of the ships, but if you see any other mistakes in ship attributes let me know. This is what peer review is for guys, you are now it.).

Edited: Therefore I re-ran the model for the Dominator only with 220 px width, yielding an average time to kill of 32.2 seconds for the Dominator. so, still on par with the Onslaught, but slightly weaker than it (Onslaught was 35.2 sconds). I'll re-run all the analyses when I have the computing time later, but since this is 1 ship out of 16 where the TTK changed roughly 10% I do not expect much of a change in the results..

If Alex wanted to change this to make Dominator be more in line with other cruisers, then changing the Dominator to have fewer armor cells without tweaking any of the visible values would help. Although, in practice, the shape of the Onslaught compensates (see Vanshilar's post), probably also differences in equipment in the real game may change things.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: RustyCabbage on October 26, 2022, 07:31:44 AM
Don't have much, if anything, to add, but thank you (and the other co-authors) for the fascinating post.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on October 26, 2022, 11:49:20 AM
One thing to keep in mind about the Conquest is that it's a broadside ship. So it's probably more accurate to model its hittable area based on its length rather than its width. This may also be true to a certain extent for the Odyssey. Under AI control, both ships seem to be more diagonal than anything, but that's a bit harder to model. For simplicity it might easier to just see the results with them from the front and from the side and then just knowing that the actual amount would be somewhere in between.

Though the lack of travel time does make the harpoon real performance have a decent size asterisk on top.

However, the Harpoon also has tracking, which means its hit rate is going to be very high. It also stays on the battlefield to seek out other targets if the one it was tracking blows up. So these attributes mean that it'll do higher damage in actual combat compared to other weapons in these models.

I will admit I find it puzzling that the Dominator ranked tougher than the Onslaught! Given that the Onslaught has more of all defenses this is weird to me, but is its larger size making it up given the strong role that shot dispersion plays in the model?

The Onslaught's frontal shape is diagonal, meaning some shots will hit armor cells that are further away and others will hit armor cells that are further in. This has the effect of increasing the number of armor cells that can be hit per unit width, and thus reduces its effective armor cell size (increasing armor density) in actual combat. So it can actually absorb more damage than a simple square block would suggest.

Attached is the Onslaught's and Dominator's "true" shape with respect to collisions (I assume it's the same for determining whether or not a weapon hits) shown in white, courtesy of the Console Command's "ShowBounds" command. You can see that the Onslaught has a lot more hills and valleys when viewed from the front, plus its sides gradually slopes away. This increases the number of armor cells that can be hit from the front, increasing its armor's effectiveness.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on October 27, 2022, 02:45:48 AM
Excellent illustration, thanks.

I've re-uploaded the scripts as an attachment to this forum as the original link apparently expired. The original data (ie. raw results from running the script) is several megabytes. Ask me if you need it, but it can be re-generated by running the scripts weaponsoptimize3 and weaponsaggregatemeantimebasic2 as they are.

Also added the Remnants table. I don't think I'll re-run the whole analysis in the near future as it seems like 1 day of computing time might not be worth the effort just to fix the Dominator value, but we'll see. I'm planning some more ambitious analyses for the future.

By the way, I tentatively think this analysis speaks to Squall-Squall-Gauss-Gauss or Squall-Hurricane-Gauss-Gauss (see large missiles x large guns x medium guns table at https://pastebin.com/Ww6piVqX) being the superior weapon choice for the Conquest, with medium guns to be decided (it seems Squall-Hurricane-Gauss-Gauss-Heavy Mauler-Heavy Mauler is one combo that did quite well at +13% ttk speed while maintaining the maximum engagement range available). This is because I think you probably generally want +25% range over +25% killing speed for a capital ship in fleet action. If this is not correct, of course, then it's one of the Mjolnir-Mjolnir layouts, or possibly Mark IX-Mjolnir if flux is an issue.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on October 27, 2022, 03:40:05 PM
Squall-Hurricane-Gauss-Gauss-HMauler-HMauler + an Ion beam is my preferred AI conquest, with the hurricane and squalls into linked fire mode so that they fire on cooldown in a combined salvo. I like putting Harpoons in the medium missiles but it sometimes doesn't fire them very much because its "stuck" firing the larger missiles.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Yunru on October 27, 2022, 03:49:58 PM
It's not a proper mathematical model unless we assume all ships are 2d spheres.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on October 28, 2022, 05:59:34 AM
So I'm looking at the real-world statistics of these weapons, thanks to the Detailed Combat Results (https://fractalsoftworks.com/forum/index.php?topic=11551.0) mod. I put 9000 ammo on a bunch of weapons, then took stock of how many was left at the end of the fight, and compared that with how many hits the Detailed Combat Results mod registered. This gives a good accounting of how many of the shots fired during the fight actually hit an enemy ship.

Note that this includes non-hits from all sources, i.e. hitting hulks, firing at a ship that blew up while the shot was in transit (including shots fired in a burst), ship maneuvering, ship moving out of range, etc. At first, it also included fighters (which notably made the Locust's hit rate very low), so I removed all fighters from the enemy fleet to collect this data. This somewhat overstates real-world efficiency (since in reality, some of the shots go toward hitting fighters) so I can redo the testing with the fighters added back in if anybody wants, but this gives a better idea of how often the weapons hit when they target enemy ships. The enemy fleet was my triple Ordos setup. I had Gunnery Implants (best target leading, -25% recoil) and Ballistic Weapon Mastery (+33% projectile speed), as well as Ballistic Rangefinder and ECCM. My flagship was either an Onslaught or a Legion modified for this purpose (2 large ballistics, 2 medium ballistics, 2 small ballistics, 2 large missiles, and 2 medium missiles all in the middle of the ship, with the other slots and fighter slots left empty, and flux increased to be able to handle all weapons), with the rest of the fleet being Gryphons with Squall/Harpoon/Breach/HVD. I took stats of only the flagship though, so the Gryphons didn't really matter. I did several runs, using a different set of weapons each run, then aggregated the numbers for each weapon across the runs.

The hit rate results were as follows:

Spoiler
Code
hits	fired	hitrate	weapon
868 1043 83.22% storm
181 289 62.63% gauss
619 777 79.67% mjolnir
1148 1540 74.55% HAG
337 1073 31.41% devastator
248 364 68.13% mark9
136 216 62.96% hellbore
2875 3804 75.58% heavy needler
517 639 80.91% HVD
208 265 78.49% heavy mauler
464 783 59.26% HAC
213 292 72.95% arbalest
196 358 54.75% heavy mortar
3585 4699 76.29% light needler
985 1290 76.36% railgun
610 742 82.21% LAG
319 440 72.50% LDAC
324 409 79.22% LAC
125 184 67.93% light mortar
340 927 36.68% hurricane
984 1908 51.57% squall
2137 3622 59.00% locust
488 820 59.51% harpoon
346 860 40.23% sabot
169 477 35.43% annihilator
37 75 49.33% pilum
[close]

For the large ballistics, I was surprised by how often the Gauss missed. But it was that way across multiple runs (hit 64 out of 103, 57 out of 92, and 60 out of 94). Not sure if it was because of the extreme range and/or because of the low turn rate. The Hellbore also missed a lot but that was expected. A lot of the Devastator's misses were simply due to it exploding before reaching the target. Overall, in terms of hit rate, it was Storm (83%) > Mjolnir (80%) > Hephaestus Assault Gun (75%) > Mark 9 (68%) > Hellbore and Gauss (63%) >> Devastator (31%).

For the medium ballistics, the Heavy Autocannon and Heavy Mortar were noticeably worse than the others in the hit rate. It was Hypervelocity Driver (81%) > Heavy Mauler (78%) > Heavy Needler (76%) > Arbalest (73%) >> Heavy Autocannon (59%) > Heavy Mortar (55%).

For the small ballistics, they were all generally fairly accurate, although the Light Mortar was a bit worse than the others.

For the large missiles, the Locust was the most accurate, once fighters were removed. However, when fighters were present, only about 35% hit enemy ships. So in actual combat, this may need to be considered, that if there are fighters, it's probably going after them rather than enemy ships. But if it's going after enemy ships, the hit rate isn't that bad, and it also provides anti-fighter coverage. A lot of the misses are just from it not finding another target in time when its target explodes, since it fires in bursts. The Squall is good at area denial but about half of them will miss. For the Hurricane, the hit rate is low because it's a burst weapon plus its missiles are relatively slow and unguided once it spreads into its burst. It does a lot of damage when it hits though (especially on big ships like the Radiant). So it's very all-or-nothing.

For medium missiles, the Harpoon had the highest hit rate. Again, a lot of its misses is just due to it not finding another target in time after its target explodes, due to it being fired in bursts.

All the missiles were also sometimes shot down by enemy PD, which lowers their hit rate.

The hit rate factors into the weapons' flux efficiency. For example, since the HVD is a lot more likely to hit than the HAC, then it turns out that the HVD actually ends up being more flux efficient in the end. Similarly, the Heavy Mauler ends up being more flux efficient than the Heavy Mortar. In both cases the former also has more range, so they're going to be more worthwhile most of the time, depending on the ship's OP.

In terms of the Conquest, it seems like the best large missile would be Squall or Locust (or one of each), with probably Gauss or Mjolnir, depending on the range that you want to fight at. Gauss would miss more but would keep them at a longer distance, while Mjolnir might be better if you "assume" that they'll close in anyway (since Remnant ships are fast). Then it looks like Harpoons and HVD. I'd pick HVD over Heavy Mauler since HVD will do more damage to shields and hull even though Heavy Mauler is better against armor. The ship would have Gauss and/or Mjolnir and/or Harpoon for anti-armor anyway.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on October 28, 2022, 09:37:08 AM
Given that you have this simulation down to an art, could I ask you to simulate the layouts predicted to be most successful vs remnants by the model for Gauss -Gauss-HVD/Mauler and Mjolnir-Mjolnir, ie Squall x2 Mjolnir x 2 Harpoon x 2 Arbalest Heavy Mortar, and Squall x 2 Gauss x2 Harpoon x 2 Heavy Mauler HVD, and see which is more effective in practice in a mono-Conquest fleet in fleet combat?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on October 28, 2022, 11:13:58 PM
Hmm. It's hard to actually use a monofleet, because in practice it's better to have a couple of other ships fulfilling other roles. In this case, the Conquest at 40 DP is a bit much to send to the initial objectives; doing so leaves only 3 ships in the middle to absorb the enemy's initial fleet. Also, I personally don't really know how to pilot a broadside ship very well.

So I took out my Onslaught, 4 Conquest, 2 Gryphon (with Squall/Harpoon/Swarmers/HVD/Xyphos) fleet, and ran it against my double Ordos test fleet (note that this includes enemy fighters now). The Gryphons grab the objectives and help with the flankers, while I pilot the Onslaught and wreck things in the middle (primarily the bigger ships, using my Proximity Charge Launchers), along with the Conquests. Since I have BotB, I start with 3 Conquests with me, and then the 4th one comes later on. Each of the Conquests had Expanded Missile Racks, Heavy Armor, ITU, Flux Distributor, Extended Shields, Stabilized Shields, Solar Shielding, ECCM, max vents, then remainder into capacitors. Each also had officers with Combat Endurance, Ballistic Mastery (elite), Gunnery Implants, Target Analysis, Missile Spec (Elite), and Ordnance Expertise. Along with the tested weapons, each Conquest also had a Burst PD Laser in the nose and 2 at the engines.

At the end of each fight, when the final Radiant is about to die, I have everyone back off and retreat. While they're retreating, I transfer command to each ship to record how much ammo they had left. Then I directly end combat using Console Command's "endcombat" command, i.e. to finish recording the stats as-is. Then I use Detailed Combat Results to check the hits and compare with the screenshots from the end of the fight. Summing up the results between the 4 Conquests, I get:

Code
squall x2, harpoon x2, mjolnir x2, arbalest, heavy mortar
shield armor hull hits fired hitrate time %squall weapon
237493 14904 33884 1443 2760 52.28% 3.738 100.00% squall
18799 9619 31065 118 288 40.97% 0.900 24.08% harpoon
76418 33703 67604 637 965 66.01% 1.508 40.34% mjolnir
10167 481 1217 69 149 46.31% 0.745 19.93% arbalest
2260 2064 1520 111 316 35.13% 0.658 17.61% heavy mortar

squall x2, harpoon x2, gauss x2, HVD, heavy mauler
shield armor hull hits fired hitrate time %squall weapon
212640 16484 32721 1432 2672 53.59% 3.618 100.00% squall
23994 16671 27116 134 288 46.53% 0.900 24.87% harpoon
76726 14551 23468 227 425 53.41% 1.771 48.94% gauss
22952 2286 5764 155 230 67.39% 1.917 52.97% HVD
6046 14400 6289 207 330 62.73% 2.292 63.33% heavy mauler

The columns "shield", "armor", and "hull" refers to the total damage to each of them. "Hits" is the number of hits recorded by Detailed Combat Results, "fired" is number of shots fired based on screenshots from the end of combat. "Hits" divided by "fired" gives the "hitrate".

We know how long it takes to fire each weapon. So, based on the number of shots fired, we can calculate how much time (in minutes) each weapon was actively firing, on average. Since Squall has the longest range, we can use that to normalize how often each weapon was active, hence the "%squall", which is how often each weapon was actively firing compared with the Squall. The Squall can fire in any direction, so it represents how often there was an enemy target within range of the Conquest.

The Harpoons, of course, ran out early. (I had them in the same weapon group as the Squalls, linked, not sure if I should put them in their own group instead.) The Mjolnirs were active about 40% of the time, while the Gauss Cannons were active around 49% of the time, probably due to their longer range. However, the HVD and Heavy Mauler were active a lot, more than the Gauss. This is probably because they outrange the Mjolnir and have much better turn rates than the Gauss Cannon; the HVD turns at 10 degrees per second, the Heavy Mauler at 20, Mjolnir at 25, and Gauss at 3. This also likely contributes to the Mjolnir's better hit rate compared with the Gauss.

The Arbalest and Heavy Mortar had relatively low hit rates, but also weren't firing most of the time. That's probably because of their lower range (700 base range); the Conquests did *not* have Ballistic Rangefinder. The Heavy Mortar has a very low projectile speed (at 500, it matches the Hellbore), plus a large spread of 20 degrees. The Arbalest also have a fairly wide spread, at 15 degrees, compared with the Heavy Mauler's 5 degrees, and the HVD and Gauss which have no spread at all.

I also did another run with Squall, Locust, Harpoon x2, Mjolnir, Gauss, HVD, and Heavy Mauler:

Code
shield	armor	hull	hits	fired	hitrate	time	%squall	weapon
128307 5691 10945 657 1140 57.63% 3.088 100.00% squall
35172 6337 29972 2092 3797 55.10% 3.520 114.01% locust
29879 13707 28760 161 288 55.90% 0.900 29.15% harpoon
26518 20226 26904 283 425 66.59% 1.328 43.02% mjolnir
46483 7019 15687 124 207 59.90% 1.725 55.87% gauss
24162 2090 8046 163 224 72.77% 1.867 60.46% HVD
4440 11253 9074 175 294 59.52% 2.042 66.13% heavy mauler

Both the Squall and Locust were in the same weapon group, so not sure why the AI fired the Locust more often. Might just be statistical noise, i.e. since ships die at different times, so sometimes it fires another Locust volley but not Squall or something, dunno. (That the Locusts were fired more often than Squalls were common to all 4 Conquests, so it's not a matter of one Conquest getting glitched out, either.) The hit rate for the Locusts was also surprisingly good for this run, so the fighters apparently didn't have as much of an effect as my initial testing. It's possible that this is because my initial testing was done using my flagship, i.e. in the center of the fleet action where it's busiest, whereas the data now is from the Conquests which were to either side of me, along with Gryphons (which do have Xyphos plus Swarmers) on the flanks.

What's interesting about it is that it was noticeably faster (note the Squall's time spent firing), but it's not immediately clear which combination may be better, i.e. Squall vs Locust, Mjolnir vs Gauss, HVD vs Heavy Mauler. Squall obviously did the bulk of the anti-shield, so that may imply that if there are 2 Squalls, then it should maybe be Mjolnir instead of Gauss (since the ship would already have enough anti-shield), and maybe Heavy Mauler over HVD. But Locust also did a lot of hull damage, which is useful for finishing off targets (along with its great tracking) -- the Harpoons run out after the first minute, so anti-hull was mostly Locust and Mjolnir after that. So I'm not sure if 2 Squalls is actually overkill. There are quite a few combinations that look pretty good.

I'll probably redo the testing with a somewhat different fleet; I'm thinking using 5 Conquests in the middle with me in a flanker (probably Medusa) on one side and the Gryphon on the other side, where we're really just there to keep enemy ships from spilling out the sides, and see how the Conquests handle the center bulk of the enemy fleet.

Edit: So, playing around with this a bit...it seems like using the "traitor" command on the last Radiant will prevent the battle from prematurely exiting out. So this is probably a cleaner way to record ammo at the end of combat.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on October 29, 2022, 01:29:28 AM
Thank you. Need to look into this with more time. But just as a quick note I do not think the Harpoons should be in the same group as the Squalls. The ships will use them suitably when in a different group - unlike for the Gryphon you should not attempt to make the ship use them fast. Based on my experience anyway.

Though to be fair it is more accurate to the model if they are fired constantly.

One thing that might still be off about the model is that currently Squall is modeled as being as accurate as other missiles. Which may not be true, but to what extent is the question. Ie. What the error should be.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on October 29, 2022, 11:06:37 AM
Yeah for Harpoons, I'll probably end up testing it both ways.

The thing is, the AI tends to fire Harpoons when the enemy target is at high flux. But the Conquest has so much other DPS that by the time the Harpoons arrive, the enemy ship is already dead or nearly so, so it results in a lot of wasted missiles. So it may be better for the Harpoons to be fired with the Squalls, so that by the time they arrive, the enemy ship is just reaching high flux, perfect for a missile volley. So I'll try it both ways, and base that decision on which way results in a higher hit rate and/or more overall damage.

Yeah the "traitor" command works well to stop the end of combat. So going forward, that's how the test will be, i.e. me in Medusa grabbing one objective and then helping the battle run smoothly, and a Gryphon at the other objective. Then the middle starts with 4 Conquests, with a 5th one joining in after I get both objectives. The Conquests also end up doing around 80% of the damage (instead of before when they were doing around 60% of the damage since I was hero-ing it up in my Onslaught running into big ships with Prox and thus grabbing the damage), so it'll be more representative of how the Conquests do on their own. On the final Radiant, when it's about to die, I'll use the "traitor" command on it to get it to switch sides, turn on god mode so the remaining shots don't kill it (although that slightly decreases everyone's hit rate), then count up the ammo on each of the ships to see how many were fired.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on November 01, 2022, 03:47:29 PM
Re: the hit rate of weapons tested with ammo and detailed combat results:

That is really interesting data! The HAC vs HVD especially because it answers a question I've long had: does the HVD accuracy make up or more than make up for its lower max DPS? Answer from your data is no, the HVD is still lower DPS in practice. But it has many other advantages so I still see it as a good gun, just not the always better gun (hooray!).

For Gauss's, in my experience their hit can suffer from the turning rate of the mounting ship being too high, so I nearly always install advanced turret gyros (I'm weird in that I consider it to be a good hullmod - not right for every ship but important when needed). They also are sometimes just a victim of their extreme range and shoot at fast targets that are very far away. Faster projectile velocity helps but not always.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Hiruma Kai on November 01, 2022, 04:42:36 PM
Re: the hit rate of weapons tested with ammo and detailed combat results:

That is really interesting data! The HAC vs HVD especially because it answers a question I've long had: does the HVD accuracy make up or more than make up for its lower max DPS? Answer from your data is no, the HVD is still lower DPS in practice. But it has many other advantages so I still see it as a good gun, just not the always better gun (hooray!).

I think you might be forgetting to account for uptime and the fact shots become harder the farther out the target is.

Naive calculation just looking at hit rate:
0.8091 * 138 DPS for HVD = 111.6 DPS for HVD
0.5926 * 214 DPS for HAC = 126.8 DPS for HAC

I'm guessing (correct me if I'm wrong) that is the calculation you did make the statement the HVD accuracy doesn't make up for the DPS difference.

However, the HVD was firing at targets the HAC didn't even bother to try shooting.

HVD had 639 shots/30 shots per minute = 21.3 minutes worth of fire
HAC had 783 shots/(129/3 shots per minute) = 18.2 minutes worth of fire.

Presumably, that means roughly 3 minutes of fire was in the range band of 800 to 1000 (times skill and ITU range bonuses I'm guessing), which would be the range at which the HVD has it's worst accuracy, given the target has the most time to change velocity.  So presumably it's accuracy in the 800 and under range band that matches the HAC is even higher than presented here.

Edit: Although re-reading, those wouldn't have been on the same ship at the same time, so making the assumption the range profiles were identical between runs is poor, unless it was a mix of HVD and HAC simultaneously?

Ideally, what you'd want is the accuracy of the HVD vs HAC within the 800 range band, not the 1000 range band vs the 800 range band.  It's also likely, that at very close ranges, it actually favors the HAC significantly, as the accuracy at a range like 100 is likely to be close to 100% for both weapons.

What is better, and typically reported by the Detailed Combat Reports mod, is in fact the total damage dealt by the weapon system in actual field combat situations, so that the ranges tested, are the ranges actually experienced.

If you make the assumption that Vanshilar's piloting produces a typical set of ranges of engagement for most ships for each attempt, we can consider the total damage dealt.
HVD: 517*275 = 142,175 kinetic damage (with 137.5 armor pen) + some ion damage
HAC: 464*300 = 139,200  (with 50 armor pen)

That looks like a win for the HVD to me, based on this data sample.  Which is kind of what you expect for a higher OP cost and less efficient weapon.

If Vanshilar's piloting does not match range for engagements between runs, then it's hard to reach a general conclusion.

Edit:
Probably the best way to answer the question on whether the accuracy makes up for the DPS is to mod the max range of the HVD to be the same as the HAC, modify a ship to have two medium ballistic mounting points directly on top of each other (so same position on the ship with same arc), put a HVD in one and a HAC in the other, and run that through a couple battles keeping track of total damage for each.

Edit 2:
Although Vanshilar's second data set is close to what you'd want now that I think about it, but it's still splitting it up into multiple runs, but hopefully the averaging of many ships approaches some kind of gaussian distribution of engagement ranges.  I'll note the Arabelest, range 700 base, had only a 20% uptime compared to the HVDs 53% 9both relative to the Squall's 100%.  Although different weapon configurations with different ranges are going to yield different average distances the ships tend to stay at, so might have non-trivial variance between runs.  As I note, ideally putting all the weapons on a single mount in the same spot with the same range is going to be the fairest in terms of trying to determine the accuracy within a particular range band.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 02, 2022, 03:41:45 AM
This is a highly interesting and relevant question. Technically it might not be exactly equivalent to mod the maximum range of the HVD to the same range as the HAC as, as you noted, closer ranges favor the HAC. This is because we might conceptualize of where each shot hits for each weapon as a sum of a uniform distribution (shot angle) and a normal distribution (error in position compared to the target being in the center of the field of fire and not moving). Now a perfectly accurate weapon only experiences the error in position component, so its chance to hit is only determined by the error in position component. But the error in position should reasonably be expected to be a function of range since, due to projectile travel time, the target vessel's position has more time to change when it is further away.

So there are two different sorts of error and the question is how they will compound. How will this work out in practice? Well, let's use the idea from the simulation that SD(positional error) ~ range and a factor f such that SD = f*range. Look only at the 6th shot onward so that the maximum angle of the HAC is 18 degrees. The HVD is always perfectly accurate. Then using the script I posted above we get this

Range Hit chance (vs Dominator, width 220 px, to hit ship)
     HAC    HVD
100  1      1
200  1      1
300  99.7%  1
400  97.4%  99.99%
500  94.1%  99.9%
600  90.7%  99.4%
700  87.2%  98.5%
800  83.8%  94.7%
900  80.9%  96.2%
1000 78.1%  94.9%
1100 75.8%  93.4%
1200 73.9%  91.8%
1300 72.0%  90.3%
1400 70.5%  88.8%
1500 68.9%  87.6%


So it is not quite the same to engage at a HAC at long range as it is to engage with a HVD at short range, as if we set HVD's range to 700 we have a 11.3% point difference in accuracy whereas if we set HAC's range to 1000 it is a difference of 16.8% points. Of course, we do not know what the actual law to describe the standard error adequately is. Maybe it's SD ~ range^2. In such a case the results would be different. Although since travel time is linear and the enemy ship's maximum travel is presumably a linear function of time, I would go with the linear assumption.

Now I would assume that the actual average engagement range is determined by how the AI behaves given a particular set of weapons, making the issue highly complex. Vanshilar's simulation results do suggest that in real combat the ship is not engaging optimally with the Arbalest, likely making it an inferior choice at least in this weapon layout.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on November 02, 2022, 04:14:37 AM
I'm still collecting data from testing and not yet ready to release the results (wanted to run them several times to make sure they weren't anomalous), but a couple of notes so far:

1. Locust firing more often than Squall: Looks like this is just because when there are enemy fighters around, only the Locusts will fire. Both are in the same group and linked, but if the AI Conquest has another weapon group active, then the Squall/Locust group is on autofire, in which case only Locusts will fire if there are enemy fighters but not enemy ships nearby. (I did add the enemy fighters back in for my new testing, since that'll give a more accurate representation of actual fights. However, it means that weapon hit rates are lower because some of those shots go toward enemy fighters instead of toward enemy ships.) So I'll still use the Squall as the yardstick for how often the other weapons fire, even though the Locust's rate exceeds 100%.

2. Harpoon being in same group as Squall (or Locust) doesn't really seem to change its hit rate. However, when it's in its own weapon group, it fires much less frequently...as in, sometimes the battle will end and the Conquests still have Harpoons left unused (this is against double Ordos). So it depends more on if you want all the Harpoons used in the first minute of combat, or gradually throughout the battle. I think that because the hit rate doesn't really change, it's probably better to have it be in the same group. The first reason is to ensure that you get the maximum benefit out of them. But the other reason is that during that first minute, you're still trying to deploy your forces, and your reinforcements from capturing objectives are still making their way to the frontlines from the bottom of the map, so that's when you're the weakest relative to the enemy fleet. So that's not a bad time to have maximum damage output.

The HAC vs HVD especially because it answers a question I've long had: does the HVD accuracy make up or more than make up for its lower max DPS?

Yeah, HAC vs HVD was one big reason why I wanted to look into this more. From using Onslaughts with both weapons, they both do about the same overall damage per weapon throughout the fight. Since the HVD has longer range but is less flux efficient, I wasn't sure if it was because the longer range meant it was firing more often, or if it meant that the HAC missed more often, for the two to end up with similar overall damage. I wanted to look at Gauss vs Mjolnir for a similar reason.

Probably the best way to answer the question on whether the accuracy makes up for the DPS is to mod the max range of the HVD to be the same as the HAC, modify a ship to have two medium ballistic mounting points directly on top of each other (so same position on the ship with same arc), put a HVD in one and a HAC in the other, and run that through a couple battles keeping track of total damage for each.

I don't have time to respond to all of it right now but I did test pretty much this way for the Gauss, on my manually piloted testing Legion: basically multiple mounts right next to each other in the middle (though not directly on top of each other because then I can't select them in the refit screen, but separated by 4 units) in a fleet of Gryphons. When Gauss's range was set to 900, Mjolnir's hit rate was around 80% but Gauss's hit rate was around 64%, so yes I think it comes down to Gauss's very low turn rate. It probably didn't help that since I'm manually piloting the ship and will start turning ahead toward the next target before I'm done with the current one, I'm basically turning a lot. This was also how I tested the weapons for their hit rate a couple months ago here (https://fractalsoftworks.com/forum/index.php?topic=25077.msg373121#msg373121), by putting the weapons almost right on top of each other and then comparing how much damage they did, although I didn't equalize the weapon ranges in that case.

AI-controlled Conquests however don't do that as much, so the Gauss's hit rate is only roughly 10% worse than that of the Mjolnir even though the Gauss has longer range. I don't think the extra distance is the reason for missing more though; the Gauss's base projectile speed is 1200, becomes 1600 due to Ballistic Mastery, and the difference between 900 base range and 1200 base range is 2220 - 1665 = 555 SU. The Gauss's projectile covers that in 1/3 of a second, and the target likely won't have much moved in so short a time for it to miss. So I think it's almost certainly due to the turn rate.

My next batch of data will be based on how the AI-controlled Conquests perform, so it won't be dependent on how I personally pilot the ship.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 02, 2022, 05:04:35 AM
Turn rate! Oh, why didn't I think of that.

So, another interesting non-linearity is that if the target vessel moves 100 px at a range of 100 then this equals a change in weapon angle of arctan(100/100)=45 degrees. However, if it moves 100 px at a range of 1000 then this equals a change in weapon angle of about 5.71 degrees to still be aimed at the target.

So if the weapon has a turn rate of 3 (Gauss Cannon) or 10 (HVD) rather than 25 (Mjolnir) this can be expected to make quite a bit of difference if that rate is in degrees / sec and the enemy is close. The turn rate of the HAC is 7, though, so this may not be particularly relevant to the HVD / HAC comparison.

Probably not much of a way to coherently model this other than as another weapon specific normally distributed error.

Edit to add: as a result of considering this, I simulated a few combats of a Gauss Conquest vs. SIM Enforcer x 2 Sunder x 2 Hammerhead x 2 under AI control (the Conquest wins, of course). Definitely could observe issues in Gauss Cannon turning, although there were also obvious misses even when the Gauss Cannon was rotated correctly. I am for now certainly adding Advanced Turret Gyros to my Gauss Conquests until it can be ruled out that it is an issue. Here is my current optimized "generalist Conquest" in burn 10 configuration suitable for space piracy.

(https://i.ibb.co/qdZFPL2/optimal-comquest.png) (https://ibb.co/DtqgSPj)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Schwartz on November 02, 2022, 06:58:11 AM
This is an exciting run-down. Please, if you can, include 4x Railgun with Ballistic Rangefinder for the Medium slots (same cost as 4x HVD, same range, better damage / flux) since Mjolnir / Locusts / Railguns is my Conquest layout and it's a stellar performer.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on November 02, 2022, 02:33:04 PM
...Presumably, that means roughly 3 minutes of fire was in the range band of 800 to 1000 (times skill and ITU range bonuses I'm guessing), which would be the range at which the HVD has it's worst accuracy, given the target has the most time to change velocity.  So presumably it's accuracy in the 800 and under range band that matches the HAC is even higher than presented here.
...

Really good point (and the rest as well, though this stood out to me)! I hadn't considered that the accuracy is absolutely going to be non-uniform across the range for HVD vs HAC, even though I'd just mentioned how it goes down for Gauss.


...Yeah, HAC vs HVD was one big reason why I wanted to look into this more. From using Onslaughts with both weapons, they both do about the same overall damage per weapon throughout the fight. Since the HVD has longer range but is less flux efficient, I wasn't sure if it was because the longer range meant it was firing more often, or if it meant that the HAC missed more often, for the two to end up with similar overall damage. I wanted to look at Gauss vs Mjolnir for a similar reason...

And for an Onslaught in particular the higher flux costs of the HVD can be a real sticking point - less so for the Conquest with its massive flux of course. I would love to see some of your data duplicated with the HVD's lowered to 800 range; they would be less effective overall weapons, but getting the "true" accuracy at 800 range would be good data to have. This would also be a good data point for CapnHector: as they pointed out they have an accuracy distribution in the model based on reasonable assumptions, but we don't have too many data points to really dial that accuracy model in.

Thanks to both of you yet again for doing so much work here! its really fascinating to see the results and I've taken a whole bunch of notes of builds to try based on it.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 02, 2022, 05:09:22 PM
Whereas this study confirms the many years Alex has spent balancing vanilla Starsector ship and weapon choices to reward skill and strategy—as I suspect many content modders only wish they could for want of time and feedback—packaging the code behind this mod into a tool that would sort combinations by time-to-kill and report results to a spreadsheet or Console Command would enable these modders to highlight imbalances whether potential or long-hidden.  I would gladly contribute to the effort.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 02, 2022, 10:23:42 PM
Whereas this study confirms the many years Alex has spent balancing vanilla Starsector ship and weapon choices to reward skill and strategy%u2014as I suspect many content modders only wish they could for want of time and feedback%u2014packaging the code behind this mod into a tool that would sort combinations by time-to-kill and report results to a spreadsheet or Console Command would enable these modders to highlight imbalances whether potential or long-hidden.  I would gladly contribute to the effort.

Unfortunately I have absolutely no clue about programming other than math and statistics. Although I should probably learn at some point as I would like to make a mod where every faction has their own version of the Conquest.

However, R is free and commonly used software, and this script is configured to accept arbitrary weapons. So if you wanted to test, say, a gun that fires 1 shot dealing 1000 HE damage every 5 seconds, and has a max spread of 1 on the first shot, 2 on the second, etc. to 5, then you would input this into the script

Spoiler

#vectors in R are written as c(value1, value2...)
#the super gun fires 1 shot on second 1, then takes 4 additional seconds to fire again
superguntics <- c(1,0,0,0,0)
#accuracy for the first shot is 1, increasing by 1 to a maximum of 5
supergunacc <- c(1,2,3,4,5)
#damage per shot, damage type (2=kinetic, 0.5=he, 0.25=frag, 1=energy), tics, weapon name, weapon accuracy over time, hit chance
#defines a data structure containing all the attributes of a weapon
supergun <- list(1000, 0.5, superguntics, "Super gun", supergunacc)
[close]

And then add this to the weapon selections, assuming the super gun is a large gun:
weapon5choices <- list(gauss, markix, mjolnir, hellbore, hephaestus, stormneedler, supergun)
weapon6choices <- list(gauss, markix, mjolnir, hellbore, hephaestus, stormneedler, supergun)

(the weapon choices are 1-2: large missiles, 3-4: medium missiles, 5-6: large guns, 7-8: medium guns by default - but it does not really matter at all, from the perspective of the code the choice of which weapons to compare in which slot is completely arbitrary, input "dummy" to have an option of having no weapon in that slot)

Finally, we must re-generate the lookup table for the accuracy distribution
generatelookuptable <- 1


So that should do it. First run weaponsoptimize3 and then weaponsaggregatemeantimebasic2. The script will run and write the tables in txt form into the user's working directory. There is a problem where there will not be a complete table for slots 3-4 (medium missiles) which I haven't had time to fix, only data for individual weapons.

Note that the script takes a very long time to run, due to the sheer number of combinations to be tested and tables to be generated, about a day on my computer. This might be clumsy, so I'll include as an attachment to this post the script providing time series visualization. This script will instead produce a chart upon manual input of weapon choice, like so:
(https://i.ibb.co/pXZ5LgT/image.png) (https://ibb.co/DKkTRNF)

Now if anybody wants to actually try running these scripts, just ask if you encounter any problems. Also, they are mostly just crunching straightforward math, so I imagine they will be easily legible to an actual programmer.

Remember when using that range and turn rate are ignored (other than as the f-factor) but are important in actual balancing.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 02, 2022, 10:52:26 PM
Unfortunately I have absolutely no clue about programming other than math and statistics. Although I should probably learn at some point as I would like to make a mod where every faction has their own version of the Conquest.

Fortunately, I know math, stats, and programming!

Quote
However, R is free and commonly used software, and this script is configured to accept arbitrary weapons. So if you wanted to test, say, a gun that fires 1 shot dealing 1000 HE damage every 5 seconds, and has a max spread of 1 on the first shot, 2 on the second, etc. to 5, then you would input this into the script

Sounds like an occasion for a GitHub! :D

Quote
Note that the script takes a very long time to run, due to the sheer number of combinations to be tested and tables to be generated, about a day on my computer. This might be clumsy, so I'll include as an attachment to this post the script providing time series visualization. This script will instead produce a chart upon manual input of weapon choice, like so:
(https://i.ibb.co/pXZ5LgT/image.png) (https://ibb.co/DKkTRNF)

I wonder if we could reduce the time to get the answer we want from the question we have ("Is it balanced?") from running the script.  In order: simplify the problem, profile the script to find any performance sink, optimize any intensive part found, and finally scale-up the computational resources.

Quote
Now if anybody wants to actually try running these scripts, just ask if you encounter any problems. Also, they are mostly just crunching straightforward math, so I imagine they will be easily legible to an actual programmer.

Remember when using that range and turn rate are ignored (other than as the f-factor) but are important in actual balancing.

Let me see the code!  :)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 02, 2022, 11:18:01 PM
Very likely there are huge performance sinks as I have not attempted to optimize at all, since I presumed to only run this a few times. Code is in attachments to message above and in message #2 of thread.

One more issue is there is currently no hard-soft flux consideration. This is just a matter of adding an extra parameter to keep track of how much "shield hp" can be "regenerated" without lowering shields. So, that would be needed if beam weapons were included.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: LinWasTaken on November 03, 2022, 02:13:32 AM
mfw i just max out shield efficiency and dissipation with some flux efficient weapons.
meanwhile other people here are calculating damage against armor, projectile speed, etc...
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 03, 2022, 02:34:24 AM
Conquest: the (over)thinking man's ship
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 03, 2022, 11:49:46 AM
Very likely there are huge performance sinks as I have not attempted to optimize at all, since I presumed to only run this a few times. Code is in attachments to message above and in message #2 of thread.

Ah, thanks!  I now see that before I could reduce the execution time of the code, I would need your help to understand its structure and function and accordingly reorganize the project, which is in three big files, each of which calls its functions in one global scope; each of those functions is individually commented, but their relationships within the file and the file itself are not.  I would have to first divide the files into smaller files, each one containing the functions for each purpose,  then consolidate into sub-routines the 'driving' code that calls these functions, and finally write one main routine calling these sub-routines.  I could then find steps that could be saved, profile the performance of the code to find time sinks, optimize the intensive portions, etc.

Quote
One more issue is there is currently no hard-soft flux consideration. This is just a matter of adding an extra parameter to keep track of how much "shield hp" can be "regenerated" without lowering shields. So, that would be needed if beam weapons were included.

Nothing like adding features! :D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 03, 2022, 06:37:50 PM
Update: I have nearly rewritten one R script file to satisfy the conditions I had stipulated but need a couple files I can't find on the thread to run it.  Once I can run the script, I can fix the errors I have surely introduced because I have learned R just to make these changes.  I have also rewritten the code to load almost all data from .csv files.

Rewritten
Code: Rewritten
#use computationally inexpensive functions to manage

shipnames <- c("glimmer","brawlerlp","vanguard","tempest","medusa",
               "hammerhead","enforcer","dominator","fulgent","brilliant",
               "radiant","onslaught","aurora","paragon","conquest",
               "champion")

#get a filename
filename <- function(x) {
    paste("optimizeweaponsbytime",
          shipnames[x],
          "allweaponswithacc.txt",
          sep = "")
}


#read a file
readfile <- function(filename) {
    read.csv(filename, sep = "\t")
}


#methods to get data tables

getFormattedTTKTable <- function(table) {
    table <- cbind(table, (1 / (table$Time / mean(table$Time)) - 1))
    table[,3] <- sprintf("%0.1f%%", table[,3] * 100)
    table <- table[order(table$Time),]
    table[,2] <- round(table[,2], digits=1)
    return(table)
}


getTTKTable <- function(dataFrame, time, label) {
    table <- aggregate(time, data = dataFrame, FUN = mean, na.rm = TRUE)
    table <- getFormattedTTKTable(table)
    colnames(table) <- c(label, "Avg. time to kill", "TTK speed score")
    return(table)
}


getGunAndLargeMissileTable <- function(dataFrameSorted) {
    table <- aggregate(Time ~ Largemissiles + Largeguns + Mediumguns,
                       data = dataFrameSorted,
                       FUN = mean,
                       na.rm = TRUE)
    table <- getFormattedTTKTable(table)
    colnames(table) <- c("Large missiles",
                         "Large guns",
                         "Medium guns",
                         "Avg. time to kill",
                         "TTK speed score")
    return(table)
}


#method to write data tables to the screen
writeTable <- function(table, filename) {
    write.table(table, file = filename, row.names = FALSE, sep = "/t")
}


getDataFrame <- function(data) {
    dataFrame <- data.frame()
   
    for (i in 1:length(shipnames)) {
        shipnamecolumn <- matrix(data=shipnames[i], ncol=1, nrow=7938)
        mainDataFrame <- rbind(dataFrame,
                               cbind(shipnamecolumn, readfile(filename(i))))
    }

    colnames(dataFrame) <- c("Ship",
                             "Time",
                             "Largemissile1",
                             "Largemissile2",
                             "Mediummissile1",
                             "Mediummissile2",
                             "Largegun1",
                             "Largegun2",
                             "Mediumgun1",
                             "Mediumgun2")
   
    return(dataFrame)
}


getDataFrameSorted <- function(data, dataFrame) {
    dataFrameSorted <- data.frame()
   
    #order large missiles, medium missiles, and large guns alphabetically by row
    #and merge cells
    for (i in 1:length(dataFrame[,1])){
        largeMissiles <- paste(t(apply(dataFrame[i,3:4], 1, sort))[2],
                               t(apply(dataFrame[i,3:4], 1, sort))[1],
                               sep = " ")
        largeGuns <- paste(t(apply(dataFrame[i,7:8], 1, sort))[1],
                           t(apply(dataFrame[i,7:8], 1, sort))[2], sep = " ")
        mediumGuns <- paste(t(apply(dataFrame[i,9:10], 1, sort))[1],
                            t(apply(dataFrame[i,9:10], 1, sort))[2], sep = " ")
        toBind <- c(dataFrame[i,1], dataFrame[i,2], largeMissiles, largeGuns,
                    mediumGuns)
        dataFrameSorted <- rbind(dataFrameSorted, toBind)
    }
   
    colnames(dataFrameSorted) <- c("Ship",
                                   "Time",
                                   "Largemissiles",
                                   "Largeguns",
                                   "Mediumguns")
                                       
    dataFrameSorted$Time <- as.double(dataFrameSorted$Time)
   
    return(dataFrameSorted)
}


#analysis with large missiles set at squallx(squall,locust,hurricane),
#large guns, and medium guns
analyze <- function(data) {

    #range with itu + bm + gi
    #(+85 total for ballistic & energy, +0 for missiles)
    gunsRangeMult <- 1.85
   
    mediumGuns <- read.csv("medium_guns.csv")
    largeGuns <- read.csv("large_guns.csv")
    mediumMissiles <- read.csv("medium_missiles.csv")
    largeMissiles <- read.csv("large_missiles.csv")
   
    dataFrame <- getDataFrame(data)
    dataFrameSorted <- getDataFrameSorted(data, dataFrame)
   
    #assemble tables
    shipTable <- getTKKTable(dataFrameSorted, Time ~ Ship, "Ship")             
    mediumGunTable <- getTTKTable(dataFrame, Time ~ mediumGuns, "Medium Gun")
    largeGunTable <- getTTKTable(dataFrame, Time ~ largeGuns, "Large Gun")
    mediumMissileTable <- getTTKTable(dataFrame, Time ~ mediumMissile,
                                      "Medium Missile")
    largeMissileTable <- getTTKTable(dataFrame, Time ~ largeMissile,
                                     "Large Missile")
    gunAndLargeMissilesTable <- getGunAndLargeMissileTable(dataFrameSorted)
   
    return(list(shipTable, "shipTable.txt",
                mediumGunTable, "mediumGunTable3.txt",
                largeGunTable, "largeGunTable3.txt",
                mediumMissileTable, "mediumMissileTable3.txt",
                largeMissileTable, "largeMissileTable3.txt",
                gunAndLargeMissilesTable, "gunAndLargeMissileTable.txt"))
}


main <- function() {
    tablesAndNames <- analyze(data)
    for (i in seq(1,tablesAndNames.length,2)) {
        writeTable(tablesAndNames[i], tablesAndNames[i+1])
    }
}

main()
[close]

CSVs
Code: medium_guns.csv
"Weapon Name","Flux Per Second","Range"
"Heavy Mortar",180,700
"Heavy Autocannon",214,800
"Arbalest",125,700
"Heavy Mauler",120,1000
"Hypervelocity Driver",175,1000
"Heavy Needler",200,700
Code: large_guns.csv
"Weapon Name","Flux Per Second","Range"
"Gauss",600,1200
"Mark IX",348,900
"Hellbore",250,900
"Storm Needler",350,700
"Mjolnir",667,900
"Hephaestus",480,900
Code: medium_missiles.csv
"Weapon Name","Flux Per Second","Range"
"Sabot",0,1400
"Harpoon",0,2500
Code: large_missiles.csv
"Weapon Name","Flux Per Second","Range"
"Squall",0,2500
"Locust",0,1400
"Hurricane",0,2500
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 03, 2022, 10:32:48 PM
Wow, you are fast and smart. Thanks for the effort and the contribution. It seems like this rewritten script does not actually write the necessary CSVs from text files, so that needs to be added.

Unfortunately some real life research work is becoming urgent. So despite being a little obsessed with Starsector just now I absolutely must set this aside for a couple of days at least and spend free time working on other things (I have somehow ended up trying to do a PhD while also doing two other degrees and working full time, the upside is there is never time to think about why you would ever want to do such a thing).

Anyway I think you mean you need the txt file (really tsv but I like Windows notepad) data containing the results from running the first script. So here it is. https://filetransfer.io/data-package/aWFSUaVb#link (https://filetransfer.io/data-package/aWFSUaVb#link)

I can't comment on every step of the code just now but here is a short version and I can go in more depth at another timepoint, also with some extra comments on already commented sections

Spoiler

# this is only needed for data visualization - can be safely removed
library(ggplot2)

#general functions
#engagementrange
range <- 1000
#fudge factor
errorsd <- 0.05
#the fudge factor should be a function of range (more error in position at greater range), but not a function of weapon firing angle, and be expressed in terms of pixels
error <- errorsd*range
#where does one shot hit within the weapon's arc of fire, in pixel difference from the target? get a random angle in degrees according to a uniform distribution,
#then consider that the circumference is 2 pi * range pixels, so the hit coordinates in pixels are
shotangle <- function(acc) return(runif(1,-acc/2,acc/2)/360*2*pi*range)
#now add a random positional error to the coordinates of the hit
hitlocation <- function(acc){
  location <- shotangle(acc)
  location <- location + rnorm(1,0,error)
  return(location)
}
#so which box was hit?
cellhit <- function(angle){
  if(angle < anglerangevector[1]) return(1)
  if(angle > anglerangevector[ship[6]+1]) return(ship[6]+2)
  for (i in 1:length(anglerangevector)) {
    if ((angle > anglerangevector) & (angle <= anglerangevector[i+1])) return(i+1)
  }
}
# this function generates the shot distribution per 1 shot with 100000 samples
#note: it is likely that a sample size of 1000 or 10000 would be acceptable in practice, reducing distribution computation time meaningfully, I just wanted to go big
createdistribution <- function(acc){
  distributionvector <- vector(mode="double", length = ship[6]+2)
  for (i in 1:100000){
    wherehit <- cellhit(hitlocation(acc))
    distributionvector[wherehit] <- distributionvector[wherehit] +1
  }
  return(distributionvector/sum(distributionvector))
}
# this is the default distribution of damage to armor cells
b <- matrix(0,nrow=5,ncol=5)
b[1:5,2:4] <- 1/30
b[2:4,1:5] <- 1/30
b[2:4,2:4] <- 1/15
b[1,1] <- 0
b[1,5] <- 0
b[5,1] <- 0
b[5,5] <- 0
#this function generates a sum of matrices multiplied by the distribution

createhitmatrix <- function(acc){
  hitmatrix <- matrix(0,5,ship[6]+4)
  distributionvector <- createdistribution(acc)
  for (i in 1:ship[6]){
    hitmatrix[,i:(i+4)] <- hitmatrix[,i:(i+4)]+b*(distributionvector[i+1])
  }
  return(hitmatrix)
}

hitchance <- function(acc){
  hitchance <- 0
  distributionvector <- createdistribution(acc)
  hitchance <- (1-(distributionvector[1]+distributionvector[ship[6]]))
  return(hitchance)
}
#for weapons with damage changing over time we need a sequence of matrices
createhitmatrixsequence <- function(accvector){
  hitmatrixsequence <- list()
  for (i in 1:length(accvector)){
    hitmatrixsequence[] <- createhitmatrix(accvector)
  }
  return(hitmatrixsequence)
}
# we do not actually use the armordamage function in practice
armordamage <- function(damage, armor, startingarmor) damage*(max(0.15,damage/(damage+max(0.05*startingarmor,armor))))
# this atrociously named function contains a logical switch which probably degrades performance, but also ensures we never divide by 0
armordamageselectivereduction <- function(damage, armor,startingarmor) {
  useminarmor <- 0
  if(armor < 0.05*startingarmor / 15) useminarmor <- 1
  if(useminarmor == 0){
    if(armor == 0) {return (damage)}
    return(damage*(max(0.15,damage/(damage+armor))))
  }
  else{
    return(damage*(max(0.15,damage/(damage+0.05*startingarmor/15))))
  }
}


#ships -
#ship, hullhp, shieldregen, shieldmax, startingarmor, widthinpixels, armorcells, name
glimmer <- c(1500, 250/0.6, 2500/0.6, 200, 78, 5, "glimmer")
brawlerlp <- c(2000, 500/0.8, 3000/0.8, 450,110,floor(110/15), "brawlerlp")
vanguard <- c(3000, 150, 2000, 600, 104, floor(104/15),"vanguard")
tempest <- c(1250, 225/0.6, 2500/0.6, 200,64,floor(64/15), "tempest")
medusa <- c(3000,400/0.6,6000/0.6,300,134,floor(134/15), "medusa")
hammerhead <- c(5000,250/0.8,4200/0.8,500,108,floor(108/16.4), "hammerhead")
enforcer <- c(4000,200,4000,900,136,floor(136/15), "enforcer")
dominator <- c(14000, 500, 10000, 1500, 180, 12, "dominator")
fulgent <- c(5000,300/0.6,5000/0.6,450, 160, floor(160/15), "fulgent")
brilliant <- c(8000,600/0.6,10000/0.6,900,160,floor(160/20),"brilliant")
radiant <- c(20000,1500/0.6,25000/0.6,1500,316,floor(316/30),"radiant")
onslaught <- c(20000,600,17000,1750,288,floor(288/30),"onslaught")
aurora <- c(8000,800/0.8,11000/0.8,800,128,floor(128/28), "aurora")
paragon <- c(18000,1250/0.6,25000/0.6,1500,330,floor(330/30),"paragon")
conquest <- c(12000,1200/1.4,20000/1.4,1200,190,floor(190/30),"conquest")
champion <- c(10000,550/0.8,10000/0.8,1250, 180,floor(180/24),"champion")

ships <- list(glimmer,brawlerlp,vanguard,tempest,medusa,hammerhead,enforcer,dominator,fulgent,brilliant,radiant,onslaught,aurora,paragon,conquest,champion)
#ships <- list(glimmer,brawlerlp)


#WEAPON ACCURACY
#missiles do not have spread
squallacc <- c(0)
locustacc <- c(0)
hurricaneacc <- c(0)
harpoonacc <- c(0)
sabotacc <- c(0)

#gauss has a spread of 0 and no increase per shot
gaussacc <- c(0)
#hephaestus has a spread of 0 and it increases by 2 per shot to a max of 10
hephaestusacc <- c(seq(0,10,2))
#mark ix has a spread of 0 and it increases by 2 per shot to a max of 15
markixacc <- c(seq(0,15,2),15)
#mjolnir has a spread of 0 and it increases by 1 per shot to a max of 5
mjolniracc <- c(seq(0,5,1))
#hellbore has a spread of 10
hellboreacc <- c(10)
#storm needler has a spread of 10
stormneedleracc <- c(10)

#squall fires 2 missiles / sec for 10 secs, then recharges for 10 secs
squalltics <- c(2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0)
#locust fires 10 missiles / sec for 4 secs, then recharges for 5 secs
locusttics <- c(10,10,10,10,0,0,0,0,0)
#hurricane fires 9 missiles every 15 seconds
hurricanetics <- c(9,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
#harpoon pod fires 4 missiles in 1 second, then recharges for 8 seconds (in reality 8.25)
harpoontics <- c(4,0,0,0,0,0,0,0,0)
#sabot pod fires 2*5 missiles, then recharges for 8 seconds (in reality 8.75 seconds and firing time is .5 seconds)
sabottics <- c(10,0,0,0,0,0,0,0,0)
#gauss fires 1 shot, then charges for 1 second
gausstics <- c(1,0)
#hephaestus fires 4 shots / sec
hephaestustics <- c(4)
#mark ix fires 4 shots every 3 seconds, taking .4 seconds for the burst, so 20 shots / 17 seconds in bursts of 4
markixtics <- c(4,0,0,4,0,0,4,0,0,4,0,0,0,4,0,0,0)
#mjolnir fires 4 shots per 3 seconds
mjolnirtics <- c(1,1,2,1,2,1,2,1,1)
#hellbore fires 1 shot per 4 seconds
hellboretics <- c(1,0,0,0)
#storm needler fires 10 shots per second
stormneedlertics <- c(10)

#arbalest fires 1 shot every 1.2 seconds, so 5 shots / 6 seconds
arbalesttics <- c(1,1,1,0,1,1)
arbalestacc <- c(0,5,10,15)
arbalest <- list(200, 2, arbalesttics, "Arbalest",arbalestacc)
#heavy mauler fires a burst of 3, taking .9 seconds to do so, then recharges for 4.5 seconds. So total of 3 shots / 5.4 seconds, so total of 15 shots / 27 seconds in bursts of 3.
heavymaulertics <- c(3,0,0,0,0,0,3,0,0,0,0,3,0,0,0,0,3,0,0,0,0,3,0,0,0,0,0)
heavymauleracc <- c(seq(0,5,1))
heavymauler <- list(200,0.5,heavymaulertics, "Heavy Mauler", heavymauleracc)
#heavy autocannon takes .6 seconds to fire a burst, then recharges for 1 second. So 3 shots / 1.6 secs, so 15 shots / 8 secs in bursts
hactics <- c(3,0,3,3,0,3,3,0)
hacacc <- c(seq(0,18,3))
hac <- list(100, 2, hactics, "Heavy Autocannon", hacacc)
hvdtics <- c(1,0,0)
hvdacc <- c(0)
hvd <- list(275,2,hvdtics,"Hypervelocity Driver", hvdacc)
#heavy needler fires a burst of 30 needles with 0.05 sec intervals (1.5 sec) and then recharges for 4.55 sec (round to 4.5 sec)
needlertics <- c(20,10,0,0,0,0)
needleracc <- c(seq(1,10,0.5))
needler <- list(50,2,needlertics,"Heavy Needler",needleracc)
#heavy mortar fires a burst of 2 rounds, taking .6 seconds to do so, then recharges for .7 seconds. So a total of 2 rounds every 1.3 seconds, so 20 rounds / 13 seconds in bursts of 2.
mortartics <- c(2,2,0,2,2,2,2,0,2,2,2,0,2)
mortaracc <- c(seq(0,20,5))
mortar <- list(110,0.5,mortartics, "Heavy Mortar", mortaracc)

#damage per shot, damage type (2=kinetic, 0.5=he, 0.25=frag, 1=energy), tics, weapon name, weapon accuracy over time, hit chance
squall <- list(250, 2, squalltics, "Squall", squallacc)
locust <- list(200, 0.25, locusttics, "Locust", locustacc)
hurricane <- list(500, 0.5, hurricanetics, "Hurricane", hurricaneacc)
harpoon <- list(750, 0.5, harpoontics, "Harpoon", harpoonacc)
sabot <- list(200, 2, sabottics, "Sabot", sabotacc)
gauss <- list(700, 2, gausstics, "Gauss", gaussacc)
hephaestus <- list(120, 0.5, hephaestustics, "Hephaestus", hephaestusacc)
markix <- list(200, 2, markixtics, "Mark IX", markixacc)
mjolnir <- list(400, 1, mjolnirtics, "Mjolnir", mjolniracc)
hellbore <- list(750, 0.5, hellboretics, "Hellbore", hellboreacc)
stormneedler <- list(50, 2, stormneedlertics, "Storm Needler", stormneedleracc)
dummy <- list(0,0,c(0),"",c(0),c(0))

#which weapons are we studying?

weapon1choices <- list(squall, locust, hurricane)
weapon2choices <- list(squall, locust, hurricane)
weapon3choices <- list(harpoon, sabot)
weapon4choices <- list(harpoon, sabot)
weapon5choices <- list(gauss, markix, mjolnir, hellbore, hephaestus, stormneedler)
weapon6choices <- list(gauss, markix, mjolnir, hellbore, hephaestus, stormneedler)
weapon7choices <- list(arbalest, hac, hvd, heavymauler, needler, mortar)
weapon8choices <- list(arbalest, hac, hvd, heavymauler, needler, mortar)

#how many unique weapon loadouts are there?

#get names of weapons from a choices list x
getweaponnames <- function(x){
  vector <- vector(mode="character")
  for (i in 1:length(x)){
  vector <- cbind(vector, x[][[4]])
  }
  return(vector)
}
#convert the names back to numbers when we are done based on a weapon choices list y
convertweaponnames <- function(x, y){
  vector <- vector(mode="integer")
  for (j in 1:length(x)) {
    for (i in 1:length(y)){
      if(x[j] == y[][[4]]) vector <- cbind(vector, i)
    }
  }
  return(vector)
}

#this section of code generates a table of all unique loadouts that we can create using the weapon choices available
generatepermutations <- 1
if (generatepermutations == 1){
#enumerate weapon choices as integers

perm1 <- seq(1,length(weapon1choices),1)
perm2 <- seq(1,length(weapon2choices),1)
perm3 <- seq(1,length(weapon3choices),1)
perm4 <- seq(1,length(weapon4choices),1)
perm5 <- seq(1,length(weapon5choices),1)
perm6 <- seq(1,length(weapon6choices),1)
perm7 <- seq(1,length(weapon7choices),1)
perm8 <- seq(1,length(weapon8choices),1)

#create a matrix of all combinations
perm1x2 <- expand.grid(perm1,perm2)
#sort, then only keep unique rows
perm1x2 <- unique(t(apply(perm1x2, 1, sort)))

perm3x4 <- expand.grid(perm3,perm4)
perm3x4 <- unique(t(apply(perm3x4, 1, sort)))

perm5x6 <- expand.grid(perm5,perm6)
perm5x6 <- unique(t(apply(perm5x6, 1, sort)))

perm7x8 <- expand.grid(perm7,perm8)
perm7x8 <- unique(t(apply(perm7x8, 1, sort)))

#now that we have all unique combinations of all two weapons, create a matrix containing all combinations of these unique combinations
allperms <- matrix(0,0,(length(perm1x2[1,])+length(perm3x4[1,])+length(perm5x6[1,])+length(perm7x8[1,])))
for(i in 1:length(perm1x2[,1])) for(j in 1:length(perm3x4[,1])) for(k in 1:length(perm5x6[,1])) for(l in 1:length(perm7x8[,1])) allperms <- rbind(allperms, c(perm1x2[i,],perm3x4[j,],perm5x6[k,],perm7x8[l,])
)
#this is just for testing, can remove
allperms
#we save this so we don't have to compute it again
saveRDS(allperms, file="allperms.RData")

} else {
  allperms <- readRDS("lookuptable.RData")
}

#now compute a main lookuptable to save on computing time
#the lookuptable should be a list of lists, so that
#lookuptable[[ship]][[weapon]][[1]] returns hit chance vector and
#lookuptable[[ship]][[weapon]][[2]] returns hit probability matrix
#time for some black R magic

#note: the lookuptable will be formulated such that there is a running index of weapons rather than sub-lists, so all weapons will be indexed consecutively so we have lookuptable [[1]][[1]] = [[ship1]][[weaponchoices1_choice1]], etc. So that is what the below section does.

#read or generate lookuptable
generatelookuptable <- 0
if(generatelookuptable == 1){

lookuptable <- list()

for (f in 1:length(ships)){
  lookuptable[[f]] <- list()
  ship <- ships[[f]]
  ship <- as.double(ship[1:6])
  #how much is the visual arc of the ship in rad?
  shipangle <- ship[5]/(2* pi *range)
 
  #how much is the visual arc of a single cell of armor in rad?
  cellangle <- shipangle/ship[6]
 
  #now assume the weapon is targeting the center of the ship's visual arc and that the ship is in the center of the weapon's firing arc
  #which cell will the shot hit, or will it miss?
  #call the cells (MISS, cell1, cell2, ... ,celli, MISS) and get a vector giving the (maximum for negative / minimum for positive) angles for hitting each
  anglerangevector <- vector(mode="double", length = ship[6]+1)
  anglerangevector[1] <- -shipangle/2
  for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector+cellangle
  #now convert it to pixels
  anglerangevector <- anglerangevector*2*pi*range
 
weaponindexmax <- length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+length(weapon8choices)
 
for (x in 1:weaponindexmax) {
  print(x)
  if(x <= length(weapon1choices)){
  weapon1<-weapon1choices[
  • ]

  if(weapon1[4] != ""){
    hitchancevector <- vector(mode = "double", length = length(weapon1[[5]]))
    for (i in 1:length(weapon1[[5]])){
      hitchancevector <- hitchance(weapon1[[5]])
    }
    lookuptable[[f]][
  • ] <- list()

    lookuptable[[f]][
  • ][[1]] <- hitchancevector

    lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon1[[5]])

  }
  }
  if((x > length(weapon1choices)) &  (x <= length(weapon1choices) + length(weapon2choices))){
    weapon2<-weapon2choices[[x-length(weapon1choices)]]
    if(weapon2[4] != ""){
      hitchancevector <- vector(mode = "double", length = length(weapon2[[5]]))
      for (i in 1:length(weapon2[[5]])){
        hitchancevector <- hitchance(weapon2[[5]])
      }
      lookuptable[[f]][
  • ] <- list()

      lookuptable[[f]][
  • ][[1]] <- hitchancevector

      lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon2[[5]])

    }
  }
 
  if((x > length(weapon1choices) + length(weapon2choices)) &  (x <= length(weapon2choices) + length(weapon1choices) + length(weapon3choices))){
    weapon3<-weapon3choices[[x-length(weapon2choices)-length(weapon1choices)]]
    if(weapon3[4] != ""){
      hitchancevector <- vector(mode = "double", length = length(weapon3[[5]]))
      for (i in 1:length(weapon3[[5]])){
        hitchancevector <- hitchance(weapon3[[5]])
      }
      lookuptable[[f]][
  • ] <- list()

      lookuptable[[f]][
  • ][[1]] <- hitchancevector

      lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon3[[5]])

    }
  } 
 
  if((x > length(weapon2choices) + length(weapon1choices) + length(weapon3choices)) &  (x <= length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon4choices))){
    weapon4<-weapon4choices[[x-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
    if(weapon4[4] != ""){
      hitchancevector <- vector(mode = "double", length = length(weapon4[[5]]))
      for (i in 1:length(weapon4[[5]])){
        hitchancevector <- hitchance(weapon4[[5]])
      }
      lookuptable[[f]][
  • ] <- list()

      lookuptable[[f]][
  • ][[1]] <- hitchancevector

      lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon4[[5]])

    }
  }
  if((x > length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon4choices)) &  (x <= length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon5choices))){
    weapon5<-weapon5choices[[x-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
    if(weapon5[4] != ""){
      hitchancevector <- vector(mode = "double", length = length(weapon5[[5]]))
      for (i in 1:length(weapon5[[5]])){
        hitchancevector <- hitchance(weapon5[[5]])
      }
      lookuptable[[f]][
  • ] <- list()

      lookuptable[[f]][
  • ][[1]] <- hitchancevector

      lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon5[[5]])

    }
  }
  if((x > length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon5choices)) &  (x <= length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon6choices))){
    weapon6<-weapon6choices[[x-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
    if(weapon6[4] != ""){
      hitchancevector <- vector(mode = "double", length = length(weapon6[[5]]))
      for (i in 1:length(weapon6[[5]])){
        hitchancevector <- hitchance(weapon6[[5]])
      }
      lookuptable[[f]][
  • ] <- list()

      lookuptable[[f]][
  • ][[1]] <- hitchancevector

      lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon6[[5]])

    }
  }
  if((x > length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon6choices)) &  (x <= length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon7choices))){
    weapon7<-weapon7choices[[x-length(weapon6choices)-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
    if(weapon7[4] != ""){
      hitchancevector <- vector(mode = "double", length = length(weapon7[[5]]))
      for (i in 1:length(weapon7[[5]])){
        hitchancevector <- hitchance(weapon7[[5]])
      }
      lookuptable[[f]][
  • ] <- list()

      lookuptable[[f]][
  • ][[1]] <- hitchancevector

      lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon7[[5]])

    }
  }
  if((x > length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon7choices)) &  (x <= length(weapon7choices) + length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon8choices))){
    weapon8<-weapon8choices[[x-length(weapon7choices)-length(weapon6choices)-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
    if(weapon8[4] != ""){
      hitchancevector <- vector(mode = "double", length = length(weapon8[[5]]))
      for (i in 1:length(weapon8[[5]])){
        hitchancevector <- hitchance(weapon8[[5]])
      }
      lookuptable[[f]][
  • ] <- list()

      lookuptable[[f]][
  • ][[1]] <- hitchancevector

      lookuptable[[f]][
  • ][[2]] <- createhitmatrixsequence(weapon8[[5]])

    }
  }
 
}
}
# save this so we don't have to re-compute it
saveRDS(lookuptable, file="lookuptable.RData")
} else {
lookuptable <- readRDS("lookuptable.RData")
}

lookup <- function(ship, weapon, var) return(lookuptable[[ship]][[weapon]][[var]])
                   
#this generates a heatmap for visualization, remove
geom_tile(lookup(8,15,2)[[2]])

#go through all ships
for (f in 1:length(ships)){

ship <- ships[[f]]
#format ship data types appropriately
shipname <- ship[[7]]
ship <- as.double(ship[1:6])







timeseriesarray <- data.frame(matrix(ncol = 7,nrow=0))



timetokill=0


shieldblock <- 0

#calculate shield damage
#timepoint %% length ... etc. section returns how many shots that weapon will fire at that point of it's cycle (ie. vector index = timepoint modulo vector index maximum)

shielddamageattimepoint <- function(weapon, timepoint){
  nohits <- weapon[[3]][(timepoint %% (length(weapon[[3]])))+1]
  if (nohits == 0) {return(0)} else {
    return(weapon[[1]]*nohits)
  }
}

#hitmatrix[hitx,hity] returns the damage allocation to that cell

damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
  # vectors in R are indexed starting from 1
  hitmatrix <- weapon[[7]][[min(shots,length(weapon[[7]]))]]
  nohits <- weapon[[3]][(timepoint %% (length(weapon[[3]])))+1]
  if (nohits == 0) {return(0)} else {
    damagesum <- 0
    for (i in 1:nohits) {
      #      damagesum <- damagesum + as.double(weapon[[1]]*hitmatrix[hitx,hity])
      damagesum <- damagesum + armordamageselectivereduction(as.double(weapon[[1]]*hitmatrix[hitx,hity]),armor,startingarmor)
      shots <- shots + 1
      hitmatrix <- weapon[[7]][[min(shots,length(weapon[[7]]))]]
    }
    return(damagesum)
  }
}



timeseries <- function(timepoint, shieldhp, armorhp, hullhp, shieldregen, shieldmax, startingarmor,armormatrix){
  weaponacc <- 0
  #are we using shield to block?
  shieldblock <- 0
  hulldamage <- 0
  if(hullhp > 0){} else {shieldhp <- 0}
 
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon1[[4]] !=""){
    weapon1mult <- unlist(weapon1[2])
    if (shieldhp > shielddamageattimepoint(weapon1, timepoint)*weapon1mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon1, timepoint)*weapon1mult*weapon1[[6]][min(weapon1shots,length(weapon1[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon1,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux <- applied later
      #2. armor and hull
      if(unlist(weapon1[2])==0.25){weapon1mult = 0.25} else {weapon1mult= 1 / unlist(weapon1[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){

#this monster of a line does the following:
#hull damage is maximum of: 0, or (weapon damage to armor cell, with multiplier - armor cell hp)/weapon multiplier
          hulldamage <- hulldamage+max(0,weapon1mult*damageattimepoint(weapon1, timepoint,armormatrix[i,j],startingarmor,i,j,weapon1shots)-armormatrix[i,j])/weapon1mult
#new armor hp at cell [i,j] is maximum of: 0, or (armor cell health - weapon damage to that section of armor at that timepoint, multiplied by overall hit probability to hit ship --- note: the damage distribution matrix is calculated such that it is NOT inclusive of hit chance, but hit chance is added separately here
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon1mult*damageattimepoint(weapon1, timepoint,armormatrix[i,j],startingarmor,i,j,weapon1shots)*weapon1[[6]][min(weapon1shots,length(weapon1[[6]]))])
        }}
     
      # now reduce hullhp by hull damage times probability to hit ship
      hullhp <- hullhp - hulldamage*weapon1[[6]][min(weapon1shots,length(weapon1[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon1shots <- weapon1shots + weapon1[[3]][(timepoint %% (length(weapon1[[3]])+1))]
 
 
  #repeat for other weapons
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon2[[4]] !=""){
    weapon2mult <- unlist(weapon2[2])
    if (shieldhp > shielddamageattimepoint(weapon2, timepoint)*weapon2mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon2, timepoint)*weapon2mult*weapon2[[6]][min(weapon2shots,length(weapon2[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon2,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux
      #2. armor and hull
      if(unlist(weapon2[2])==0.25){weapon2mult = 0.25} else {weapon2mult= 1 / unlist(weapon2[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){
          hulldamage <- hulldamage+max(0,weapon2mult*damageattimepoint(weapon2, timepoint,armormatrix[i,j],startingarmor,i,j,weapon2shots)-armormatrix[i,j])/weapon2mult
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon2mult*damageattimepoint(weapon2, timepoint,armormatrix[i,j],startingarmor,i,j,weapon2shots)*weapon2[[6]][min(weapon2shots,length(weapon2[[6]]))])
        }}
     
     
      hullhp <- hullhp - hulldamage*weapon2[[6]][min(weapon2shots,length(weapon2[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon2shots <- weapon2shots + weapon2[[3]][(timepoint %% (length(weapon2[[3]])+1))]
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon3[[4]] !=""){
    weapon3mult <- unlist(weapon3[2])
    if (shieldhp > shielddamageattimepoint(weapon3, timepoint)*weapon3mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon3, timepoint)*weapon3mult*weapon3[[6]][min(weapon3shots,length(weapon3[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon3,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux
      #2. armor and hull
      if(unlist(weapon3[2])==0.25){weapon3mult = 0.25} else {weapon3mult= 1 / unlist(weapon3[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){
          hulldamage <- hulldamage+max(0,weapon3mult*damageattimepoint(weapon3, timepoint,armormatrix[i,j],startingarmor,i,j,weapon3shots)-armormatrix[i,j])/weapon3mult
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon3mult*damageattimepoint(weapon3, timepoint,armormatrix[i,j],startingarmor,i,j,weapon3shots)*weapon3[[6]][min(weapon3shots,length(weapon3[[6]]))])
        }}
     
     
      hullhp <- hullhp - hulldamage*weapon3[[6]][min(weapon3shots,length(weapon3[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon3shots <- weapon3shots + weapon3[[3]][(timepoint %% (length(weapon3[[3]])+1))]
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon4[[4]] !=""){
    weapon4mult <- unlist(weapon4[2])
    if (shieldhp > shielddamageattimepoint(weapon4, timepoint)*weapon4mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon4, timepoint)*weapon4mult*weapon4[[6]][min(weapon4shots,length(weapon4[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon4,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux
      #2. armor and hull
      if(unlist(weapon4[2])==0.25){weapon4mult = 0.25} else {weapon4mult= 1 / unlist(weapon4[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){
          hulldamage <- hulldamage+max(0,weapon4mult*damageattimepoint(weapon4, timepoint,armormatrix[i,j],startingarmor,i,j,weapon4shots)-armormatrix[i,j])/weapon4mult
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon4mult*damageattimepoint(weapon4, timepoint,armormatrix[i,j],startingarmor,i,j,weapon4shots)*weapon4[[6]][min(weapon4shots,length(weapon4[[6]]))])
        }}
     
     
      hullhp <- hullhp - hulldamage*weapon4[[6]][min(weapon4shots,length(weapon4[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon4shots <- weapon4shots + weapon4[[3]][(timepoint %% (length(weapon4[[3]])+1))]
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon5[[4]] !=""){
    weapon5mult <- unlist(weapon5[2])
    if (shieldhp > shielddamageattimepoint(weapon5, timepoint)*weapon5mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon5, timepoint)*weapon5mult*weapon5[[6]][min(weapon5shots,length(weapon5[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon5,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux
      #2. armor and hull
      if(unlist(weapon5[2])==0.25){weapon5mult = 0.25} else {weapon5mult= 1 / unlist(weapon5[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){
          hulldamage <- hulldamage+max(0,weapon5mult*damageattimepoint(weapon5, timepoint,armormatrix[i,j],startingarmor,i,j,weapon5shots)-armormatrix[i,j])/weapon5mult
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon5mult*damageattimepoint(weapon5, timepoint,armormatrix[i,j],startingarmor,i,j,weapon5shots)*weapon5[[6]][min(weapon5shots,length(weapon5[[6]]))])
        }}
     
     
      hullhp <- hullhp - hulldamage*weapon5[[6]][min(weapon5shots,length(weapon5[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon5shots <- weapon5shots + weapon5[[3]][(timepoint %% (length(weapon5[[3]])+1))]
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon6[[4]] !=""){
    weapon6mult <- unlist(weapon6[2])
    if (shieldhp > shielddamageattimepoint(weapon6, timepoint)*weapon6mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon6, timepoint)*weapon6mult*weapon6[[6]][min(weapon6shots,length(weapon6[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon6,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux
      #2. armor and hull
      if(unlist(weapon6[2])==0.25){weapon6mult = 0.25} else {weapon6mult= 1 / unlist(weapon6[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){
          hulldamage <- hulldamage+max(0,weapon6mult*damageattimepoint(weapon6, timepoint,armormatrix[i,j],startingarmor,i,j,weapon6shots)-armormatrix[i,j])/weapon6mult
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon6mult*damageattimepoint(weapon6, timepoint,armormatrix[i,j],startingarmor,i,j,weapon6shots)*weapon6[[6]][min(weapon6shots,length(weapon6[[6]]))])
        }}
     
     
      hullhp <- hullhp - hulldamage*weapon6[[6]][min(weapon6shots,length(weapon6[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon6shots <- weapon6shots + weapon6[[3]][(timepoint %% (length(weapon6[[3]])+1))]
 
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon7[[4]] !=""){
    weapon7mult <- unlist(weapon7[2])
    if (shieldhp > shielddamageattimepoint(weapon7, timepoint)*weapon7mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon7, timepoint)*weapon7mult*weapon7[[6]][min(weapon7shots,length(weapon7[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon7,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux
      #2. armor and hull
      if(unlist(weapon7[2])==0.25){weapon7mult = 0.25} else {weapon7mult= 1 / unlist(weapon7[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){
          hulldamage <- hulldamage+max(0,weapon7mult*damageattimepoint(weapon7, timepoint,armormatrix[i,j],startingarmor,i,j,weapon7shots)-armormatrix[i,j])/weapon7mult
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon7mult*damageattimepoint(weapon7, timepoint,armormatrix[i,j],startingarmor,i,j,weapon7shots)*weapon7[[6]][min(weapon7shots,length(weapon7[[6]]))])
        }}
     
     
      hullhp <- hullhp - hulldamage*weapon7[[6]][min(weapon7shots,length(weapon7[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon7shots <- weapon7shots + weapon7[[3]][(timepoint %% (length(weapon7[[3]])+1))]
 
  #1. shields. if shieldhp is sufficient, use shield to block
  if (weapon8[[4]] !=""){
    weapon8mult <- unlist(weapon8[2])
    if (shieldhp > shielddamageattimepoint(weapon8, timepoint)*weapon8mult){
      shieldhp <- shieldhp - shielddamageattimepoint(weapon8, timepoint)*weapon8mult*weapon8[[6]][min(weapon8shots,length(weapon8[[6]]))]
      shieldhp <- max(shieldhp, 0)
      if(shielddamageattimepoint(weapon8,timepoint) > 0) {shieldblock <- 1}
    } else {
      #if you did not use shield to block, regenerate flux
      #2. armor and hull
      if(unlist(weapon8[2])==0.25){weapon8mult = 0.25} else {weapon8mult= 1 / unlist(weapon8[2])}
      #2.1. damage armor and hull
      hulldamage <- 0   
     
      #damageattimepoint <- function(weapon, timepoint, armor, startingarmor, hitx, hity, shots){
     
      for (j in 1:length(armormatrix[1,])){
        for (i in 1:length(armormatrix[,1])){
          hulldamage <- hulldamage+max(0,weapon8mult*damageattimepoint(weapon8, timepoint,armormatrix[i,j],startingarmor,i,j,weapon8shots)-armormatrix[i,j])/weapon8mult
          armormatrix[i,j] <- max(0,armormatrix[i,j]-weapon8mult*damageattimepoint(weapon8, timepoint,armormatrix[i,j],startingarmor,i,j,weapon8shots)*weapon8[[6]][min(weapon8shots,length(weapon8[[6]]))])
        }}
     
     
      hullhp <- hullhp - hulldamage*weapon8[[6]][min(weapon8shots,length(weapon8[[6]]))]
      hullhp <- max(hullhp, 0)
     
     
    }
  }
  weapon8shots <- weapon8shots + weapon8[[3]][(timepoint %% (length(weapon8[[3]])+1))]
 
 
 
  hullhp <- hullhp - hulldamage
  hullhp <- max(hullhp, 0)
 
  if(hullhp==0) armorhp <- 0
 
 
 
 
  armorhp <- sum(armormatrix)*15/((ship[[6]]+4)*5)
  if(hullhp==0) armorhp <- 0
 
 
  if (shieldblock==0){shieldhp <- min(shieldmax,shieldhp+shieldregen)}
  return(list(timepoint, shieldhp, armorhp, hullhp, shieldregen, shieldmax, startingarmor,armormatrix))
}

totaltime = 500



armorhp <- ship[4]
shieldhp <- ship[3]
hullhp <- ship[1]
shieldregen <- ship[2]
shieldmax <- ship[3]
armorhp <- ship[4]
startingarmor <- ship[4]
weapon1shots <- 1
weapon2shots <- 1
weapon3shots <- 1
weapon4shots <- 1
weapon5shots <- 1
weapon6shots <- 1
weapon7shots <- 1
weapon8shots <- 1

armormatrix <- matrix(ship[4]/15,5,ship[6]+4)
startingarmormatrixsum <- sum(armormatrix)

#now what we do here is we go through all the permutations using the running index, which is i+j+k+l+m+n+o+p for weapons 8
for (z in 1:length(allperms[,1])) {
  i <- allperms[z,1]
  j <- allperms[z,2]
  k <- allperms[z,3]
  l <- allperms[z,4]
  m <- allperms[z,5]
  n <- allperms[z,6]
  o <- allperms[z,7]
  p <- allperms[z,8]
 
#for (i in 1:length(weapon1choices)) {
  weapon1<-weapon1choices[]
#  for (j in 1:length(weapon2choices)) {
    weapon2<-weapon2choices[[j]]
#    for (k in 1:length(weapon3choices)) {
      weapon3<-weapon3choices[[k]]
#      for (l in 1:length(weapon4choices)) {
        weapon4<-weapon4choices[[l]]
#        for (m in 1:length(weapon5choices)) {
          weapon5<-weapon5choices[[m]]
#          for (n in 1:length(weapon6choices)) {
            weapon6<-weapon6choices[[n]]
#            for (o in 1:length(weapon7choices)) {
              weapon7<-weapon7choices[
  • ]

#              for (p in 1:length(weapon8choices)) {
                weapon8<-weapon8choices[[p]]
                #lookup <- function(ship, weapon, var) return(lookuptable[[ship]][[weapon]][[var]])
                if(weapon1[4] != ""){
                  weapon1[[6]] <- lookup(f,i,1)
                  weapon1[[7]] <- lookup(f,i,2)
                }
               
                if(weapon2[4] != ""){
                  weapon2[[6]] <- lookup(f,i+j,1)
                  weapon2[[7]] <- lookup(f,i+j,2)
                }
               
                if(weapon3[4] != ""){
                  weapon3[[6]] <- lookup(f,i+j+k,1)
                  weapon3[[7]] <- lookup(f,i+j+k,2)
                }
               
                if(weapon4[4] != ""){
                  weapon4[[6]] <- lookup(f,i+j+k+l,1)
                  weapon4[[7]] <- lookup(f,i+j+k+l,2)
                }
               
                if(weapon5[4] != ""){
                  weapon5[[6]] <- lookup(f,i+j+k+l+m,1)
                  weapon5[[7]] <- lookup(f,i+j+k+l+m,2)
                }
               
                if(weapon6[4] != ""){
                  weapon6[[6]] <- lookup(f,i+j+k+l+m+n,1)
                  weapon6[[7]] <- lookup(f,i+j+k+l+m+n,2)
                }
                if(weapon7[4] != ""){
                  weapon7[[6]] <- lookup(f,i+j+k+l+m+n+o,1)
                  weapon7[[7]] <- lookup(f,i+j+k+l+m+n+o,2)
                }
                if(weapon8[4] != ""){
                  weapon8[[6]] <- lookup(f,i+j+k+l+m+n+o+p,1)
                  weapon8[[7]] <- lookup(f,i+j+k+l+m+n+o+p,2)
                }
               
#time series - run time series at point t, save it to state, update values according to state, re-run time series, break if ship dies
for (t in 1:totaltime){
  state <- timeseries(t,shieldhp,armorhp,hullhp,shieldregen,shieldmax,startingarmor,armormatrix)
  shieldhp <- state[[2]]
  armorhp <- state[[3]]
  hullhp <- state[[4]]
  flux <- shieldmax - shieldhp
  armormatrix <- state[[8]]
  if(hullhp == 0){flux <- 0
  if (timetokill == 0){timetokill <- t
  break}
  }
 
}
if (timetokill ==0){timetokill <- NA}

tobind <- c(timetokill,unlist(weapon1[4]),unlist(weapon2[4]),unlist(weapon3[4]),unlist(weapon4[4]),unlist(weapon5[4]),unlist(weapon6[4]),unlist(weapon7[4]),unlist(weapon8[4]))
timeseriesarray <- rbind(timeseriesarray,tobind)

armorhp <- ship[4]
shieldhp <- ship[3]
hullhp <- ship[1]
shieldregen <- ship[2]
shieldmax <- ship[3]
armorhp <- ship[4]
startingarmor <- ship[4]
weapon1shots <- 1
weapon2shots <- 1
weapon3shots <- 1
weapon4shots <- 1
weapon5shots <- 1
weapon6shots <- 1
weapon7shots <- 1
weapon8shots <- 1
armormatrix <- matrix(ship[4]/15,5,ship[6]+4)
startingarmormatrixsum <- sum(armormatrix)
timetokill <- 0
#          }
#        }
#      }
#    }
#  }
#}
# }
}
#}
colnames(timeseriesarray) <-  c("Timetokill", "Weapon1", "Weapon2", "Weapon3", "Weapon4", "Weapon5", "Weapon6", "Weapon7", "Weapon8")

sortbytime <- timeseriesarray[order(as.integer(timeseriesarray$Timetokill)),]

write.table(sortbytime, file = paste("optimizeweaponsbytime",shipname,"allweaponswithacc.txt", sep=""), row.names=FALSE, sep="\t")
}

[close]

This does the italicize things so as a zip in attachment.

Edit to add: there might be an actual error in the running index at the loop at the end as that should probably be (max i + max j + max k ... + p). So if so then that should be fixed. Whether it is so depends on whether I accounted for this in the permutations matrix. If correct then that would have resulted in using improper accuracy parameters and is reason enough to re-compute. Unfortunately simply no time to go through the code and check for now, need to do later. Apologies about a possible error.

E2: yes there is an error. You should replace i with length(weapon1choices) etc from weapon 2 onwards in the running index. This means individual weapon timeseries were correct but the weapon comparison script was assigning incorrect accuracy to weapons much of the time. Well, good thing to have found it. I can probably set my one laptop on re-crunching numbers while doing real research on the other, so expect an update on data and numbers later.

Corrected, this section of the code reads
code

    if(weapon1[4] != ""){
      weapon1[[6]] <- lookup(f,i,1)
      weapon1[[7]] <- lookup(f,i,2)
    }
   
    if(weapon2[4] != ""){
      weapon2[[6]] <- lookup(f,length(weapon1choices)+j,1)
      weapon2[[7]] <- lookup(f,length(weapon1choices)+j,2)
    }
   
    if(weapon3[4] != ""){
      weapon3[[6]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+k,1)
      weapon3[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+k,2)
    }
   
    if(weapon4[4] != ""){
      weapon4[[6]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+l,1)
      weapon4[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+l,2)
    }
   
    if(weapon5[4] != ""){
      weapon5[[6]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+m,1)
      weapon5[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+m,2)
    }
   
    if(weapon6[4] != ""){
      weapon6[[6]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+n,1)
      weapon6[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+n,2)
    }
    if(weapon7[4] != ""){
      weapon7[[6]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+o,1)
      weapon7[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+o,2)
    }
    if(weapon8[4] != ""){
      weapon8[[6]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+p,1)
      weapon8[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+p,2)
    }
[close]

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 04, 2022, 12:50:11 AM
Wow, you are fast and smart. Thanks for the effort and the contribution. It seems like this rewritten script does not actually write the necessary CSVs from text files, so that needs to be added.

It wasn't speed or brains but spending hours copying, pasting, fiddling, deleting, wondering, repeating, and searching the internet for R syntax, the table manipulation parts of which still largely elude me, while thinking, "Gosh, what have I gotten myself into?  I hope I won't have to spend as much time on the next two files!", and feeling as though I were navigating a maze with a tin bucket stuck over my head.  ???

You're welcome for the contribution, though!  This software could show modders how their weapons balance.  I don't understand what making it "write the necessary CSVs from text files" would entail, so would please you elaborate?

Quote
Unfortunately some real life research work is becoming urgent. So despite being a little obsessed with Starsector just now I absolutely must set this aside for a couple of days at least and spend free time working on other things (I have somehow ended up trying to do a PhD while also doing two other degrees and working full time, the upside is there is never time to think about why you would ever want to do such a thing).

I think you have filled your days with academic undertakings and that this project is an extra-curricular one.

Quote
Anyway I think you mean you need the txt file (really tsv but I like Windows notepad) data containing the results from running the first script. So here it is. https://filetransfer.io/data-package/aWFSUaVb#link (https://filetransfer.io/data-package/aWFSUaVb#link)

Yes indeed!  Thanks so much.  I can now continue development!  :D  Wooo!

Quote
I can't comment on every step of the code just now but here is a short version and I can go in more depth at another timepoint, also with some extra comments on already commented sections

Thanks, I appreciate that you took the time to write these comments, which will help me understand the code!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 04, 2022, 01:47:51 AM
You're welcome for the contribution, though!  This software could show modders how their weapons balance.  I don't understand what making it "write the necessary CSVs from text files" would entail, so would please you elaborate?

Your script requests a file called e.g. medium_guns.csv but does not write one, whereas weaponsoptimize3.R outputs files in .txt. Probably just WIP. E: nevermind, I see, you meant to include the ones in your post.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 04, 2022, 04:56:04 AM
Alright so as planned above I let this run while I was working on some graphs and glms etc. on my other laptop and it's done. The results were not much changed from the original but now the running index problem is corrected. This also fixes the incorrect width on the Dominator. I'll update post #1 with the results. Here is the raw data: https://filetransfer.io/data-package/C1m2MwU8#link , and attached are fixed scripts. This also fixed the medium missiles problem which was pretty trivial.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 04, 2022, 10:01:02 PM
Thanks for the code and raw data!  I have almost entirely restructured the optimize script and had an idea while working on it: if only we could directly calculate the probability of hitting a cell, then the code could skip the lengthy step of randomly generating hundreds or thousands of angles and errors and comparing them to the corresponding angle bounds.

Reviewing this code to turn it into a formula, I have noticed a portion I might not understand but, if I do, suspect to be incorrect.
Code
#where does one shot hit within the weapon's arc of fire, in pixel difference from the target? get a random angle in degrees according to a uniform distribution, 
#then consider that the circumference is 2 pi * range pixels, so the hit coordinates in pixels are
shotangle <- function(acc) return(runif(1,-acc/2,acc/2)/360*2*pi*range)
#now add a random positional error to the coordinates of the hit
hitlocation <- function(acc){
  location <- shotangle(acc)
  location <- location + rnorm(1,0,error)
  return(location)
}
I would write the function to generate a random shot angle as
Code
shotAngle <- function(spread) {
    return(runif(1, -spread/2, spread/2) * pi / 180))
}

Meanwhile, vectorizing the code and switching from linear to binary search should noticeably speed it up.
Code
#engagementrange
RANGE <- 1000

#fudge factor
ERROR_SD <- 0.05

#the fudge factor should be a function of range (more error in
#position at greater range), but not a function of weapon
#firing angle, and be expressed in terms of pixels
ERROR <- ERROR_SD * RANGE

SHOTS <- 100000

getShotAngles <- function(spread) {
    return(runif(SHOTS, -spread / 2, spread / 2) * pi / 180)
}

#now add a random positional error to the coordinates of the hit
getShotErrors <- function() {
    return(rnorm(SHOTS, 0, ERROR))
}

#so which box was hit?
isCellHit <- function(angle, intervalBounds, ship) {
    if (angle < intervalBounds[1]) return(1)
    if (angle > tail(1, intervalBounds)) return(ship[6] + 2)
    left <- 1
    right <- length(intervalBounds)
    while(TRUE) {
        index <- floor((left + right) / 2)
        if (angle <= bounds[index]) right <- index
        else if (angle > bounds[index + 1]) left <- index
        else return(index + 1)
    }
}

#generates the shot distribution per shot
getDistribution <- function(spread) {
    distribution <- vector(mode = "double", length = ship[6] + 2)
    hitLocations <- isCellHit(getShotAngles(spread) + getShotErrors())
    distribution[hitLocations] <- distribution[hitLocations] + 1
    return(distribution / sum(distribution))
}
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 04, 2022, 10:30:44 PM
It's great to have a real programmer looking at this. See, I've only learned the bits necessary to do statistical stuff and never give a thought to code clarity or what might be an optimal search algorithm. I'll probably learn stuff that I can even use in real life.

Anyway, you are absolutely correct that the shot angle function does not generate a shot angle.

The shot angle, in radians, is runif(1,-acc/2,acc/2)/360*2*pi. However, on the next line we are adding a random positional error (rather than angle error) to this. So despite my function being called shotangle it actually does what it says in the comments and returns arc length (with sign) from 0 to where the shot hits, to which is then added a random positional error and then compared vs the ship 's and individual armor cells' width to find hit location on ship.

You could also generate the distribution analytically. It is given by Y=X_1+X_2 where Y is shot hit coordinate in pixels, X_1 is the uniform distribution U(-arclength, arclength) and X_2 is the normal distribution N(0,errorSD^2). Then compute P(upperbound > Y > lowerbound) for each cell. We had an argument in the previous thread about what is the correct distribution and ended up simulating.

I'll add that if you are considering generalizing this method to arbitrary ranges and ship widths then should probably correct for curvature. I have not done that as the effect is expected to be negligible at range 1000, but really we would be interested in the coordinates of the projection of the edge of each armor cell to the circle defined by range, so if the range is short and the ship is wide then this would be something that needs to be considered. To avoid this issue, you might instead of the arc length conceptualize that the cells and positional error are not along an arc defined by weapon range, but are instead perpendicular to the firing ship. Then instead of calculating arc length we would use tan(shotangle)*range so the position hit is understood to be the height of the right angle triangle defined by shot angle and range.

Like this (excuse my drawing skills):

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on November 05, 2022, 01:18:17 AM
I have almost entirely restructured the optimize script and had an idea while working on it: if only we could directly calculate the probability of hitting a cell, then the code could skip the lengthy step of randomly generating hundreds or thousands of angles and errors and comparing them to the corresponding angle bounds.

To me that's an important benefit of CapnHector's "probability wave" approach: that all the damages could be calculated in one step. Coding-wise, this would be vectorizing all sorts of steps. I haven't really looked at the code, but the way I coded it in my Excel spreadsheet was:

1. Make use of the fact that across the armor band, the top and bottom cells have the same values, and the middle 3 rows of cells have the same values. Therefore, the armor cell matrix only needs to be 2 rows. This does mean you have to be careful to do *2 and *3 at the appropriate points though, but means you only need to carry around a 2 x N matrix rather than a 5 x N matrix representing the armor cells.

2. Calculate the equivalent armor rating for each hittable armor cell, which is a 1 x (N - 4) vector, since the weapon can never hit the farther 2 armor cells on either side. That's a straightforward formula to relate the armor matrix to this vector, which is:

1/30 * [ 2 * (0 i+1 i+2 i+3 0) of row 1 of armor matrix + 3 * (i 2*(i+1) 2*(i+2) 2*(i+3) i+4) of row 2 of armor matrix]

or the following matrix dotted with each applicable cell of the matrix of armor cells:

0 1/15 1/15 1/15 0
1/10 1/5 1/5 1/5 1/10

noting that the i's are the indices of the elements, and the inner "2*" refers to multiplying the value of that element by 2. But I'm not sure if there's an easy function to do this in one step, or if you end up having to use a for loop for each element. Then each element in this 1 x (N - 4) vector is set to 0.05 * base armor rating if the value is below that.

3. Input the desired probability distribution, which is also a 1 x (N - 4) vector, and does not necessarily sum to 1. (Any amount less than 1 represents a miss.) This is the part you're talking about here.

4. Calculate the hit damage reduction for each hittable armor cell. That's just a 1 x (N - 4) vector of hit_str/(hit_str + equiv_armor_rating), with a minimum value of 0.15, but again I'm not sure if there's an easy coding way to apply it cell-by-cell in one step.

5. Calculate the damage applied if the shot hit each armor cell. This could be combined with step #4 if desired. If it's a separate step, then it's just a simple scalar (weapon damage) multiplying the hit damage reduction vector.

6. Multiply the damage applied in step #5 with the probability vector in step #3. This is just multiplying two vectors together, element by element, resulting in a new 1 x (N - 4) vector that represents the net damage applied in this round if the shot hit that cell.

7. Expand the net damage vector in step #6 into the damage to be applied to each armor cell. So you're expanding a 1 x (N - 4) vector into a 2 x N matrix by applying a convolution (using the matrix above as the kernel). Not sure if R has a shortcut for this, but since convolution is common in image processing, it might have some function or another to do this as one step.

8. Subtract out the damage matrix from the armor matrix. Not sure how easy it is to apply checking for what happens when no more armor is left; in my case, I used it as a way to count up the hull damage.

Anyway, a lot of these computation steps can probably be vectorized, which would speed up computation greatly. Not sure if some of them might already be done that way in CapnHector's code, but it's worth considering if it's not.

I've attached a condensed version of the Excel spreadsheet I used for my earlier calculations; hope it helps. (The original spreadsheet goes down to 500 steps, and also has a second page with similar code except it hits 1 cell of the cells, generated at random, for each of the 500 steps, but it doesn't fit on the forum.) The input and results areas are to the right, in column AD onward, whereas the calculations are done to the left. The user-input hit probability vector are cells E5:X5. The subsequent steps automatically copy the values in those cells if they're modified. It allows for a hittable cell width of up to 20 cells, which means the armor matrix is 24 cells wide. However there's no harm if you use fewer cells than that; what I have set there is 15.

As a side note, in terms of future expansion, it may be worth:

1. Having the minimum damage reduction value be a user-defined parameter and not hard-coded, since Polarized Armor can reduce it from 0.15 to 0.10.
2. Having the ability to decrease incoming damage to armor by a user-defined percentage (i.e. for Impact Mitigation). Note that the way it works is it actively decreases the incoming shot's damage, i.e. if there's armor, then the incoming shot's damage goes from 400 to 300. (A double whammy since it decreases not just the damage but the hit strength as well, which makes Impact Mitigation really good.)
2. Having the ability to decrease incoming damage to hull by a user-defined percentage (i.e. for Damage Control).
4. Having the ability to decrease the armor hit strength by a user-defined percentage (i.e. for the upcoming Ablative Armor hullmod).

These are not necessary but may be useful for playing around in the future with different builds and different targets.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 05, 2022, 06:53:19 AM
It's great to have a real programmer looking at this.

I wish!  I'm just a hobbyist.  :'(

Quote
See, I've only learned the bits necessary to do statistical stuff and never give a thought to code clarity or what might be an optimal search algorithm. I'll probably learn stuff that I can even use in real life.

Imagining a program as a graph of nodes representing functions and data structures and edges representing function calls reveals what makes code well-formed: every group of statements doing one thing is wrapped inside a function, and functions call other functions, which call other functions, and so on until the 'driver' code at the root of the graph, which should be structured to clearly expose the program to a reader who does not know the contents of the functions.  A node can also contain  functions and data structures associated into an objects, which can act on itself or other objects.  Beware of absolutist ideas of how to arrange these programs, for great holy wars have been waged over them, leaving only long-unread flaming comments and clumsy, scarcely-used languages, but rather make your program run to suit your purpose, readable to make it easy to modify, and finally efficient to save time if it is slow but must be run often.

Quote
Anyway, you are absolutely correct that the shot angle function does not generate a shot angle.

Please, please name functions after what they do.  I  :(

Quote
The shot angle, in radians, is runif(1,-acc/2,acc/2)/360*2*pi. However, on the next line we are adding a random positional error (rather than angle error) to this. So despite my function being called shotangle it actually does what it says in the comments and returns arc length (with sign) from 0 to where the shot hits, to which is then added a random positional error and then compared vs the ship 's and individual armor cells' width to find hit location on ship.

Now I understand!  Thanks for explaining.

Quote
You could also generate the distribution analytically. It is given by Y=X_1+X_2 where Y is shot hit coordinate in pixels, X_1 is the uniform distribution U(-arclength, arclength) and X_2 is the normal distribution N(0,errorSD^2). Then compute P(upperbound > Y > lowerbound) for each cell. We had an argument in the previous thread about what is the correct distribution and ended up simulating.

Running the numbers both ways and comparing might reveal whether the methods are close enough for the user's purposes.  I speculate that the angle from the front left corner of each cell to the weapon to the front right corner of that cell, divided by the angle from the left edge of the ship to the weapon to the right edge of the ship, would approach the per-cell probabilities of large random samples.

Quote
I'll add that if you are considering generalizing this method to arbitrary ranges and ship widths then should probably correct for curvature. I have not done that as the effect is expected to be negligible at range 1000, but really we would be interested in the coordinates of the projection of the edge of each armor cell to the circle defined by range, so if the range is short and the ship is wide then this would be something that needs to be considered. To avoid this issue, you might instead of the arc length conceptualize that the cells and positional error are not along an arc defined by weapon range, but are instead perpendicular to the firing ship. Then instead of calculating arc length we would use tan(shotangle)*range so the position hit is understood to be the height of the right angle triangle defined by shot angle and range.

I profiled r*tan(x) and r*x and found the former to take ten times longer, and |r*tan(x) - r*x| is single-digit for angles less than about 10 degrees, though I have found decreasing the range to increase the interval of angles across which the difference is as small.

Quote
Like this (excuse my drawing skills):

Yeah, this makes good sense.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 05, 2022, 10:51:43 AM
Quote
Anyway, you are absolutely correct that the shot angle function does not generate a shot angle.

Please, please name functions after what they do.  I  :(

In my defense that is what the function originally did, but then I changed my mind  :P

Quote
You could also generate the distribution analytically. It is given by Y=X_1+X_2 where Y is shot hit coordinate in pixels, X_1 is the uniform distribution U(-arclength, arclength) and X_2 is the normal distribution N(0,errorSD^2). Then compute P(upperbound > Y > lowerbound) for each cell. We had an argument in the previous thread about what is the correct distribution and ended up simulating.

Running the numbers both ways and comparing might reveal whether the methods are close enough for the user's purposes.  I speculate that the angle from the front left corner of each cell to the weapon to the front right corner of that cell, divided by the angle from the left edge of the ship to the weapon to the right edge of the ship, would approach the per-cell probabilities of large random samples.

What do you mean? Isn't that the exact thing the program does? I was too lazy/busy to calculate what the distribution is but fortunately somebody ahs already done it:
https://www.cambridge.org/core/books/abs/measurement-uncertainty-and-probability/the-sum-of-normal-and-uniform-variates/DECD19AD9E3D68916CC4B04532418F58

We can implement this in R as


G <- function(y) return(y*pnorm(y) + dnorm(y))
fEz <- function(z, a, b) return((1/2/b)*(pnorm(z/a+b/a)-pnorm(z/a-b/a)))
PrEltZ <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))


so now a cell's hit probability should be given by PrEltZ(cellupperbound, a, b)-PrEltZ(cellowerbound, a, b) where b is the maximum arc length/right angled triangle height for each shot and a is the standard deviation of the normal error.

See also
matrix <- matrix(data=0,nrow = 1000,ncol=1)
for (i in 1:1000) {
matrix[i,1] <- fEz((i-500)/100,1,4)
}
plot(matrix)
(https://i.ibb.co/kX2GGfn/image.png) (https://ibb.co/jDk882K)

Let's see if this produces the same distribution that the program does. For the simulation we have


range<-1000
error <- 1000*0.05
shotangle <- function(acc) return(runif(1,-acc/2,acc/2)/360*2*pi*range)
#now add a random positional error to the coordinates of the hit
hitlocation <- function(acc){
  location <- shotangle(acc)
  location <- location + rnorm(1,0,error)
  return(location)
}

matrix <- matrix(data=0,nrow = 100000,ncol=1)
for (i in 1:100000) {
  matrix[i,1] <- hitlocation(10)
}
hist(matrix[matrix<200 & matrix > -200])

Result
(https://i.ibb.co/cyDyKvG/image.png) (https://ibb.co/0nhnpXg)

For the mathematical expression we have

matrix <- matrix(data=0,nrow = 400,ncol=1)
for (i in 1:400) {
  matrix[i,1] <- fEz(i-200,error,10/2/360*2*pi*range)
}
plot(matrix)


Result
(https://i.ibb.co/KmHG2nq/image.png) (https://ibb.co/8XVBrk5)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 05, 2022, 11:26:50 AM
What do you mean? Isn't that the exact thing the program does? I was too lazy/busy to calculate what the distribution is but fortunately somebody ahs already done it:
https://www.cambridge.org/core/books/abs/measurement-uncertainty-and-probability/the-sum-of-normal-and-uniform-variates/DECD19AD9E3D68916CC4B04532418F58

I meant that I think a simplified analytical model—divide the angle for each cell by the total angle of the ship—would well-enough approximate the numerical one for the users' purposes. I didn't expect covering the subject to entail a big, thick book: statistics is an enormous field!  :o

Quote
We can implement this in R as


G <- function(y) return(y*pnorm(y) + dnorm(y))
fEz <- function(z, a, b) return(pnorm(z/a+b/a)-pnorm(z/a-b/a))
PrEltZ <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))


so now a cell's hit probability should be given by PrEltZ(cellupperbound, a, b)-PrEltZ(cellowerbound, a, b) where b is the maximum arc length/right angled triangle height for each shot and a is the standard deviation of the normal error.

Wooo!  Thanks for finding and implementing the relevant functions—how convenient!  :D  Now I need your help ensuring the code is readable and maintainable: would you please tell me whether you want to keep fEz and then accordingly spell-out the names of the functions and that of the variables y and z and fill the bracketed docstring portions?

#[in a sentence, what does this function do?]
#
#[if that sentence isn't enough, start the paragraph for more here and leave a line]
#
#y - [what is y?]
G <- function(y) return(y * pnorm(y) + dnorm(y))

#[in a sentence, what does this function do?]
#
#[if that sentence isn't enough, start the paragraph for more here and leave a line]
#
#z - [what is z?]
#maximumDeviation - distance a shot may deviate from [what?] and still hit [which?] cell
#standardDeviation - standard deviation of [what?]
fEz <- function(z, maximumDeviation, standardDeviation) {
    return(pnorm((z + maximumDeviation) / standardDeviation)
            - pnorm((z - maximumDeviation) / standardDeviation))
}

#[in a sentence, what does this function do?]
#
#[if that sentence isn't enough, start the paragraph for more here and leave a line]
#
#z - [what is z?]
#maximumDeviation - distance a shot may deviate from [what?] and still hit [which?] cell
#standardDeviation - standard deviation of [what?]
PrEltZ <- function(z, maximumDeviation, standardDeviation) {
    return(standardDeviation
            / (2 * maximumDeviation)
            * (G((z + maximumDeviation) / standardDeviation)
                - G((z - maximumDeviation) / standardDeviation))
}
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 05, 2022, 12:25:06 PM
Let's see. I mean PrEltZ is probably not the user friendliest function name so let's try to make that more reasonable

#Calculate where shots will hit on average. The coordinates of an individual hit are E = E1+E2, where E1 is a uniformly distributed variable representing shot angle and E2 is a randomly distributed variable representing error in the ships' position compared to a situation where the enemy ship is in the middle of the weapon's firing arc and not moving, and the weapon targets the middle.
#
#This helper function is used to define the cumulative distribution function used to calculate where shots hit. y is any real number.
G <- function(y) return(y * pnorm(y) + dnorm(y))

#If we want to show the probability density function of E, it is given by the expression f_E(z)=1/(2b)(Phi(z/a+b/a)-Phi(z/a-b/a)), where the uniform distribution is U(-b,b) and the normal distribution is N(0,a^2) (see Willink 2013, pp. 261)
#This is not used in the code but might be useful for bug testing
#maximumDeviation - the maximum signed arc length of the shot given a maximum firing angle and a range, representing weapon accuracy
#standardDeviation - standard deviation of the normal distribution representing positional error
#z is the shot hit location in pixels and fEz is the probability density function of shot hit location given the average assumptions
#fEz <- function(z, maximumDeviation, standardDeviation) {
#    return(1/(2*maximumDeviation)*pnorm((z + maximumDeviation) / standardDeviation)
#            - pnorm((z - maximumDeviation) / standardDeviation))
#}

#This function returns the cumulative distribution function of the distribution E at point z. That is, given we have a shot that hits a random location E, what is the probability that the shot's hit coordinates are less than z?
#maximumDeviation - the maximum signed arc length of the shot given a maximum firing angle and a range, representing weapon accuracy. Note that 0 represents hitting the weapon's target.
#standardDeviation - standard deviation of the normal distribution representing positional error
probabilityHitLocationLessThanZ <- function(z, maximumDeviation, standardDeviation) {
    return(standardDeviation
            / (2 * maximumDeviation)
            * (G((z + maximumDeviation) / standardDeviation)
                - G((z - maximumDeviation) / standardDeviation))
}



This part of the code will probably simply require a little knowledge of statistics and unfortunately not much can be done about that. Also note that I forgot the /2b from fEz when posted initially, added here. E: fixed a typo and added a mention of 0 representing weapon hitting target.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 05, 2022, 01:56:33 PM
Let's see. I mean PrEltZ is probably not the user friendliest function name so let's try to make that more reasonable

 :)

Quote
This part of the code will probably simply require a little knowledge of statistics and unfortunately not much can be done about that. Also note that I forgot the /2b from fEz when posted initially, added here. E: fixed a typo and added a mention of 0 representing weapon hitting target.

I'll do my best.  Also, long lines of code and long comments are more readable with broken into shorter lines.  Also, what would a descriptive name for G and y be?

#Cumulative distribution of hit location
#
#The location of a hit is the distance in pixels from the
#ship centerline and equals the sum of a uniformly
#random shot angle and a normally random perpendicular
#distance from the ship centerline
#
#angle - angle between the line from the weapon to the ship
#        and the line along which the weapon aims
#
hitCumulativeDistribution <- function(aimAngle) {
    return(aimAngle * pnorm(aimAngle) + dnorm(aimAngle))
}

#Return hit probability density at an aim angle.
#
#The probability density function of hit coordinate is
#f(z) = 1 / (2b) * (Phi((z + b) / a) - Phi((z - b) / a)
#where the uniform distribution is U(-b, b)
#and the normal distribution is N(0, a^2)
#(see Willink 2013, pp. 261)
#This function is not used but might help bug testing
#
#maximumDeviation - maximum signed arc length of the shot
#                   given a maximum firing angle and a range,
#                   representing weapon accuracy
#standardDeviation - standard deviation of the normal distribution
#                    representing positional error
#hitLocation - shot hit location in pixels
#
hitProbabilityDensity <- function(hitLocation,
                                  maximumDeviation,
                                  standardDeviation)
{
    return(1/(2 * maximumDeviation)
            * pnorm((hitLocation + maximumDeviation) / standardDeviation)
            - pnorm((hitLocation - maximumDeviation) / standardDeviation))
}

#Returns the cumulative distribution function
#
#The cumulative distribution of the hit probability density is,
#the probability that the hit coordinate is within some width
#of the ship centerline?
#
#width - maximum acceptable hit coordinate
#maximumDeviation - the maximum signed arc length of the
#                   shot given a maximum firing angle and a
#                   range, representing weapon accuracy.
#                   0 represents hitting the target.
#standardDeviation - standard deviation of the normal
#                   distribution representing positional error
#
probabilityHitWithinWidth <- function(width,
                                      maximumDeviation,
                                      standardDeviation)
{
    return(standardDeviation
            / (2 * maximumDeviation)
            * (hitCumulativeDistribution((width + maximumDeviation) / standardDeviation)
                - hitCumulativeDistribution((width - maximumDeviation) / standardDeviation))
}


I am still a little confused on two points.  First, although I know that hit location and aim angle are almost interchangeable, I am losing track of whether these functions are talking about one or the other exactly.  And why do the cumulative distribution function comments mention a uniform distribution, which is not called in the code?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 05, 2022, 02:32:38 PM
These functions are talking about hit location as it would not make sense to add a random parameter describing location to an angle. The functions do not call a uniform distribution, since the functions describe the distribution generated by the sum E=E1+E2, where E1~U(-b,b) and E2~N(0,sd^2), and according to Bhattacharjee et al. who did the math and published it once this follows the distribution presented ie. it can be expressed in terms of the standard normal distribution as presented. I suppose you could call G distributionPlusDensity but I don't know if that's really more informative and it is hard to see what G exactly represents when haven't done the math. But calculating the convolution of the two distributions to re-do it is non-trivial and I don't have access to the original article right now.

Anyway, to make sure this works, here is another test. This is what I get from running the original script with 100000 samples:
Spoiler
#SHIP
#dominator, hullhp, shieldregen, shieldmax, startingarmor, widthinpixels, armorcells
ship <- c(14000, 500, 10000, 1500, 220, 12)

#engagementrange
range <- 1000

#weaponaccuracy - this will be made a function of time and weapon later. the accuracy of a hellbore is 10
acc <- 10

#fudge factor
errorsd <- 0.05
#the fudge factor should be a function of range (more error in position at greater range), but not a function of weapon firing angle, and be expressed in terms of pixels
error <- errorsd*range

#where does one shot hit within the weapon's arc of fire, in pixel difference from the target? get a random angle in degrees according to a uniform distribution,
#then consider that the circumference is 2 pi * range pixels, so the hit coordinates in pixels are
shotangle <- function(acc) return(runif(1,-acc/2,acc/2)/360*2*pi*range)

#how much is the visual arc of the ship in rad?
shipangle <- ship[5]/(2* pi *range)

#how much is the visual arc of a single cell of armor in rad?
cellangle <- shipangle/ship[6]

#now assume the weapon is targeting the center of the ship's visual arc and that the ship is in the center of the weapon's firing arc
#which cell will the shot hit, or will it miss?
#call the cells (MISS, cell1, cell2, ... ,celli, MISS) and get a vector giving the (maximum for negative / minimum for positive) angles for hitting each
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector+cellangle

#now convert it to pixels
anglerangevector <- anglerangevector*2*pi*range

#this vector will store the hits
shipcellvector <- vector(mode="double", length = ship[6]+2)

#now add a random positional error to the coordinates of the hit
hitlocation <- function(acc){
  location <- shotangle(acc)
  location <- location + rnorm(1,0,error)
  return(location)
}

#so which box was hit?
cellhit <- function(angle){
  if(angle < anglerangevector[1]) return(1)
  if(angle > anglerangevector[ship[6]+1]) return(ship[6]+2)
  for (i in 1:length(anglerangevector)) {
    if ((angle > anglerangevector) & (angle <= anglerangevector[i+1])) return(i+1)
  }
}


# this function generates the shot distribution per 1 shot with 100000 samples
createdistribution <- function(acc){
  distributionvector <- vector(mode="double", length = ship[6]+2)
  for (i in 1:100000){
    wherehit <- cellhit(hitlocation(acc))
    distributionvector[wherehit] <- distributionvector[wherehit] +1
  }
  return(distributionvector/sum(distributionvector))
}

# this is the default distribution of damage to armor cells
b <- matrix(0,nrow=5,ncol=5)
b[1:5,2:4] <- 1/30
b[2:4,1:5] <- 1/30
b[2:4,2:4] <- 1/15
b[1,1] <- 0
b[1,5] <- 0
b[5,1] <- 0
b[5,5] <- 0

#this function generates a sum of matrices multiplied by the distribution

createhitmatrix <- function(acc){
  hitmatrix <- matrix(0,5,ship[6]+4)
  distributionvector <- createdistribution(acc)
  for (i in 1:ship[6]){
    hitmatrix[,i:(i+4)] <- hitmatrix[,i:(i+4)]+b*(distributionvector[i+1])
  }
  return(hitmatrix)
}


plot(createdistribution(10))
[close]
(https://i.ibb.co/Qn0Y7Xh/image.png) (https://ibb.co/2PJc05R)

and here is what I get from running this:

probabilityvector <- vector(mode="double",14)
for (i in 2:length(probabilityvector)){
    probabilityvector[\i] <- PrEltZ(anglerangevector[\i],error,10/2/360*2*pi*range)-PrEltZ(anglerangevector[i-1],error,10/2/360*2*pi*range)
}
probabilityvector[1] <- PrEltZ(anglerangevector[1],error,10/2/360*2*pi*range)
probabilityvector[14] <- 1-PrEltZ(anglerangevector[13],error,10/2/360*2*pi*range)
plot(probabilityvector)
(note, escaped i due to bbcode)
(https://i.ibb.co/x8wrtC1/image.png) (https://ibb.co/vvfM6w3)

But the mathematical method is very much faster.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 05, 2022, 03:19:41 PM
These functions are talking about hit location as it would not make sense to add a random parameter describing location to an angle.

Ok, I have changed angle references to position references.

Quote
The functions do not call a uniform distribution, since the functions describe the distribution generated by the sum E=E1+E2, where E1~U(-b,b) and E2~N(0,sd^2), and according to Bhattacharjee et al. who did the math and published it once this follows the distribution presented ie. it can be expressed in terms of the standard normal distribution as presented.

I have changed the documentation and structure of the third function accordingly but wonder if it is clear and accurate.  Please tell me what changes I should make further.

Quote
I suppose you could call G distributionPlusDensity but I don't know if that's really more informative and it is hard to see what G exactly represents when haven't done the math. But calculating the convolution of the two distributions to re-do it is non-trivial and I don't have access to the original article right now.

I have inlined the first function into the third.  It is ugly but perhaps less-so than the helper function.

Quote
Anyway, to make sure this works, here is another test. This is what I get from running the original script with 100000 samples:
Spoiler
#SHIP
#dominator, hullhp, shieldregen, shieldmax, startingarmor, widthinpixels, armorcells
ship <- c(14000, 500, 10000, 1500, 220, 12)

#engagementrange
range <- 1000

#weaponaccuracy - this will be made a function of time and weapon later. the accuracy of a hellbore is 10
acc <- 10

#fudge factor
errorsd <- 0.05
#the fudge factor should be a function of range (more error in position at greater range), but not a function of weapon firing angle, and be expressed in terms of pixels
error <- errorsd*range

#where does one shot hit within the weapon's arc of fire, in pixel difference from the target? get a random angle in degrees according to a uniform distribution,
#then consider that the circumference is 2 pi * range pixels, so the hit coordinates in pixels are
shotangle <- function(acc) return(runif(1,-acc/2,acc/2)/360*2*pi*range)

#how much is the visual arc of the ship in rad?
shipangle <- ship[5]/(2* pi *range)

#how much is the visual arc of a single cell of armor in rad?
cellangle <- shipangle/ship[6]

#now assume the weapon is targeting the center of the ship's visual arc and that the ship is in the center of the weapon's firing arc
#which cell will the shot hit, or will it miss?
#call the cells (MISS, cell1, cell2, ... ,celli, MISS) and get a vector giving the (maximum for negative / minimum for positive) angles for hitting each
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector+cellangle

#now convert it to pixels
anglerangevector <- anglerangevector*2*pi*range

#this vector will store the hits
shipcellvector <- vector(mode="double", length = ship[6]+2)

#now add a random positional error to the coordinates of the hit
hitlocation <- function(acc){
  location <- shotangle(acc)
  location <- location + rnorm(1,0,error)
  return(location)
}

#so which box was hit?
cellhit <- function(angle){
  if(angle < anglerangevector[1]) return(1)
  if(angle > anglerangevector[ship[6]+1]) return(ship[6]+2)
  for (i in 1:length(anglerangevector)) {
    if ((angle > anglerangevector) & (angle <= anglerangevector[i+1])) return(i+1)
  }
}


# this function generates the shot distribution per 1 shot with 100000 samples
createdistribution <- function(acc){
  distributionvector <- vector(mode="double", length = ship[6]+2)
  for (i in 1:100000){
    wherehit <- cellhit(hitlocation(acc))
    distributionvector[wherehit] <- distributionvector[wherehit] +1
  }
  return(distributionvector/sum(distributionvector))
}

# this is the default distribution of damage to armor cells
b <- matrix(0,nrow=5,ncol=5)
b[1:5,2:4] <- 1/30
b[2:4,1:5] <- 1/30
b[2:4,2:4] <- 1/15
b[1,1] <- 0
b[1,5] <- 0
b[5,1] <- 0
b[5,5] <- 0

#this function generates a sum of matrices multiplied by the distribution

createhitmatrix <- function(acc){
  hitmatrix <- matrix(0,5,ship[6]+4)
  distributionvector <- createdistribution(acc)
  for (i in 1:ship[6]){
    hitmatrix[,i:(i+4)] <- hitmatrix[,i:(i+4)]+b*(distributionvector[i+1])
  }
  return(hitmatrix)
}


plot(createdistribution(10))
[close]
(https://i.ibb.co/Qn0Y7Xh/image.png) (https://ibb.co/2PJc05R)

and here is what I get from running this:

probabilityvector <- vector(mode="double",14)
for (i in 2:length(probabilityvector)){
    probabilityvector[\i] <- PrEltZ(anglerangevector[\i],error,10/2/360*2*pi*range)-PrEltZ(anglerangevector[i-1],error,10/2/360*2*pi*range)
}
probabilityvector[1] <- PrEltZ(anglerangevector[1],error,10/2/360*2*pi*range)
probabilityvector[14] <- 1-PrEltZ(anglerangevector[13],error,10/2/360*2*pi*range)
plot(probabilityvector)
(note, escaped i due to bbcode)
(https://i.ibb.co/x8wrtC1/image.png) (https://ibb.co/vvfM6w3)

But the mathematical method is very much faster.

Fantastic!  Finding this an analytical method spares waiting for the code to finish or optimizing it.  Here's the code redone.  Thanks for helping me make it readable! 

Spoiler

#Return hit probability density for a distance
#from the ship centerline.
#
#The hit probability density a at distance c
#the ship centerline is the sum of the uniform
#distribution U(-b, b) and normal distribution
#N(0, a^2), and this sum is equivalent to
#
#(Phi((c + b) / a) - Phi((c - b) / a)) / 2b
#
#where Phi is the cumulative density function
#(see Willink 2013, pp. 261).
#
#Note: This function is not used but might help
#bug-testing.
#
#position - in pixels from centerline of ship
#spread - maximum signed arc length of the shot,
#         given maximum firing angle and range,
#         representing weapon accuracy
#standardDeviation - standard deviation of the 
#                    normal distribution, 
#                    representing positional
#                    error
#
hitProbabilityDensity <- function(position,
                                  spread,
                                  standardDeviation)
{
    return(1/(2 * spread)
            * pnorm((position + spread) / standardDeviation)
            - pnorm((position - spread) / standardDeviation))
}


#Return cumulative distribution of hit
#probability density.
#
#The cumulative distribution of hit probability
#density is the probability the hit coordinate
#is within some number of pixels of the ship
#centerline.
#
#width - maximum acceptable hit coordinate
#spread - maximum signed arc length of the
#         shot, given maximum firing angle and
#         range, representing weapon inaccuracy
#standardDeviation - standard deviation of the
#                    normal distribution,
#                    representing positional
#                    error
#
hitProbabilityWithinWidth <- function(width,
                                      spread,
                                      standardDeviation)
{
    left <- (width + spread) / standardDeviation
    right <- (width - spread) / standardDeviation
    left <- (left * pnorm(left) + dnorm(left)
    right <- (right * pnorm(right) + dnorm(right))
    return(standardDeviation / (2 * spread) * (right - left)
}

[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 05, 2022, 11:22:12 PM
Quote
The functions do not call a uniform distribution, since the functions describe the distribution generated by the sum E=E1+E2, where E1~U(-b,b) and E2~N(0,sd^2), and according to Bhattacharjee et al. who did the math and published it once this follows the distribution presented ie. it can be expressed in terms of the standard normal distribution as presented.

I have changed the documentation and structure of the third function accordingly but wonder if it is clear and accurate.  Please tell me what changes I should make further.
...
Fantastic!  Finding this an analytical method spares waiting for the code to finish or optimizing it.  Here's the code redone.  Thanks for helping me make it readable! 



Looking good and you have made it very readable, great. There are a few clarifications I'd make and also the final function seems to have dropped a right ) unless I am mistaken. With suggestions below. E: fixed another missing bracket I think and clarified it still a little more.
Spoiler

#Return hit probability for a distance
#from the ship centerline.
#
#The hit coordinates are given by the random variable Y,
#which is X1 + X2, where X1 is drawn from the uniform distribution
#U(-b,b) and X2 is drawn from the normal distribution N(0,a^2).
#
#Given that this is the case, the
#the hit probability density f(c) at distance c
#the ship centerline is the convolution of the probability density functions
#of the uniform distribution U(-b, b) and normal distribution
#N(0, a^2), and this is equivalent to
#
#(Phi((c + b) / a) - Phi((c - b) / a)) / 2b
#
#where Phi is the cumulative distribution function of the standard normal distribution
#(see Willink 2013, pp. 261).
#
#The probability density function:
#Note: This function is not used but might help
#bug-testing, as it lets you visualize the hits that result from weapon accuracy.
#as a graph by visualizing the function.

#
#position - in pixels from centerline of ship
#spread - maximum signed arc length of the shot,
#         given maximum firing angle and range,
#         representing weapon accuracy
#standardDeviation - standard deviation of the 
#                    normal distribution, 
#                    representing positional
#                    error
#
hitProbabilityDensity <- function(position,
                                  spread,
                                  standardDeviation)
{
    return(1/(2 * spread)
            * pnorm((position + spread) / standardDeviation)
            - pnorm((position - spread) / standardDeviation))
}


#Return cumulative distribution of hit
#probability density function.
#
#Using the above notation, let G(x)=x Phi (x) + phi (x),
#where Phi(x) is the cumulative distribution function of the standard
#normal distribution and phi(x) the probability density function of same
#
#Then the cumulative distribution function of c is given by
#F(c) = (G((c + b) / a) - G((c - b) / a)) * a / 2b (Willink 2013, Bhattacharjee et al. 1963)
#

#The cumulative distribution of hit probability
#density is the probability the hit coordinate
#is within some number of pixels of the ship
#centerline.
#
#width - maximum acceptable hit coordinate
#spread - maximum signed arc length of the
#         shot, given maximum firing angle and
#         range, representing weapon inaccuracy
#standardDeviation - standard deviation of the
#                    normal distribution,
#                    representing positional
#                    error
#
hitProbabilityWithinWidth <- function(width,
                                      spread,
                                      standardDeviation)
{
    left <- (width + spread) / standardDeviation
    right <- (width - spread) / standardDeviation
    left <- (left * pnorm(left) + dnorm(left))
    right <- (right * pnorm(right) + dnorm(right))
    return(standardDeviation / (2 * spread) * (right - left))
}

[close]

Apparently this distribution is a something that comes up in mathematical physics, for example when we are measuring something that has a normal distribution using a measurement technique that contains a uniform error, so that is why it is possible to find literature on it.

Just for fun, here are the hit probabilities (per pixel) that result from weapon maximum spread angle going from 0 to 20 with a standard deviation of 50px for the ship's position (ie range = 1000).
Spoiler

G <- function(y) return(y*pnorm(y) + dnorm(y))
fEz <- function(z, a, b) return((1/2/b)*(pnorm(z/a+b/a)-pnorm(z/a-b/a)))
PrEltZ <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
matrix <- matrix(data=0,nrow = 10521,ncol=3)
for (j in 0:20){
  for (i in 0:500){
    matrix[i+j*501,] <- c(i-250,fEz(i-250,50,j/2/360*2*pi*1000),j)
  }
}

plot(matrix[,1],matrix[,2],col=matrix[,3],pch=20,xlab="pixel coordinate",ylab="hit probability")
[close]
(https://i.ibb.co/fMx6h45/image.png) (https://ibb.co/zs49yGY)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 06, 2022, 02:30:12 PM
Looking good and you have made it very readable, great. There are a few clarifications I'd make and also the final function seems to have dropped a right ) unless I am mistaken. With suggestions below. E: fixed another missing bracket I think and clarified it still a little more.
Spoiler

#Return hit probability for a distance
#from the ship centerline.
#
#The hit coordinates are given by the random variable Y,
#which is X1 + X2, where X1 is drawn from the uniform distribution
#U(-b,b) and X2 is drawn from the normal distribution N(0,a^2).
#
#Given that this is the case, the
#the hit probability density f(c) at distance c
#the ship centerline is the convolution of the probability density functions
#of the uniform distribution U(-b, b) and normal distribution
#N(0, a^2), and this is equivalent to
#
#(Phi((c + b) / a) - Phi((c - b) / a)) / 2b
#
#where Phi is the cumulative distribution function of the standard normal distribution
#(see Willink 2013, pp. 261).
#
#The probability density function:
#Note: This function is not used but might help
#bug-testing, as it lets you visualize the hits that result from weapon accuracy.
#as a graph by visualizing the function.

#
#position - in pixels from centerline of ship
#spread - maximum signed arc length of the shot,
#         given maximum firing angle and range,
#         representing weapon accuracy
#standardDeviation - standard deviation of the 
#                    normal distribution, 
#                    representing positional
#                    error
#
hitProbabilityDensity <- function(position,
                                  spread,
                                  standardDeviation)
{
    return(1/(2 * spread)
            * pnorm((position + spread) / standardDeviation)
            - pnorm((position - spread) / standardDeviation))
}


#Return cumulative distribution of hit
#probability density function.
#
#Using the above notation, let G(x)=x Phi (x) + phi (x),
#where Phi(x) is the cumulative distribution function of the standard
#normal distribution and phi(x) the probability density function of same
#
#Then the cumulative distribution function of c is given by
#F(c) = (G((c + b) / a) - G((c - b) / a)) * a / 2b (Willink 2013, Bhattacharjee et al. 1963)
#

#The cumulative distribution of hit probability
#density is the probability the hit coordinate
#is within some number of pixels of the ship
#centerline.
#
#width - maximum acceptable hit coordinate
#spread - maximum signed arc length of the
#         shot, given maximum firing angle and
#         range, representing weapon inaccuracy
#standardDeviation - standard deviation of the
#                    normal distribution,
#                    representing positional
#                    error
#
hitProbabilityWithinWidth <- function(width,
                                      spread,
                                      standardDeviation)
{
    left <- (width + spread) / standardDeviation
    right <- (width - spread) / standardDeviation
    left <- (left * pnorm(left) + dnorm(left))
    right <- (right * pnorm(right) + dnorm(right))
    return(standardDeviation / (2 * spread) * (right - left))
}

[close]

Thanks for the praise and improvements!

Quote
Apparently this distribution is a something that comes up in mathematical physics, for example when we are measuring something that has a normal distribution using a measurement technique that contains a uniform error, so that is why it is possible to find literature on it.

Surprise, surprise, combining two of the simplest distributions describes much of what we see!

Quote
Just for fun, here are the hit probabilities (per pixel) that result from weapon maximum spread angle going from 0 to 20 with a standard deviation of 50px for the ship's position (ie range = 1000).
Spoiler

G <- function(y) return(y*pnorm(y) + dnorm(y))
fEz <- function(z, a, b) return((1/2/b)*(pnorm(z/a+b/a)-pnorm(z/a-b/a)))
PrEltZ <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
matrix <- matrix(data=0,nrow = 10521,ncol=3)
for (j in 0:20){
  for (i in 0:500){
    matrix[i+j*501,] <- c(i-250,fEz(i-250,50,j/2/360*2*pi*1000),j)
  }
}

plot(matrix[,1],matrix[,2],col=matrix[,3],pch=20,xlab="pixel coordinate",ylab="hit probability")
[close]

Woaaaaahhhh... that looks nice.

Update on the code.  I think it can get the data it needs from mod files without the user having to enter it or load the game, enabling a standalone app, which would entail an R runtime and the necessary duck-tape in the install folder.  Do you know how to run a .R file from a shell file? rewriting this project in Python because running any R code requires installing R whereas running Python code does not.

I know Python, and if you do not, then I highly recommend it because the language is much more coder-friendly while having rich libraries to do what R does and more.  Code can be in more than one file, classes are built-in rather than an afterthought, variables are assigned with "=" instead of "<-", no more "{" and "}" to open and close code blocks, etc.

Spoiler

from statistics import NormalDist

def hit_probability_density(position, spread, standard_deviation):
    """
    Return hit probability for a distance from the ship centerline.

    The hit coordinates are the sum of one number drawn from
    a uniform distribution from -b to b and another drawn from
    a normal distribution of mean 0 and standard deviation a^2.

    Therefore, the hit probability density f(c) at distance c from
    the ship centerline is the convolution of the respective
    probability density functions, and this convolution is
    equivalent to

    (Phi((c + b) / a) - Phi((c - b) / a)) / 2b

    where Phi is the cumulative distribution function of the
    standard normal distribution (see Willink 2013, pp. 261).

    Note: This function is unused, but plotting it might help
    bug-testing by visualizing the relationship between hits
    and weapon inaccuracy.

    position - in pixels from centerline of ship
    spread - maximum signed arc length of the shot, given
             maximum firing angle and range, representing weapon
             inaccuracy
    standardDeviation - standard deviation of the normal
                        distribution, representing positional error
    """
    return (1/(2 * spread)
            * NormalDist().cdf((position + spread) / standard_deviation)
            - NormalDist().cdf((position - spread) / standard_deviation))


def hit_probability_within_width(width, spread, standard_deviation):
    """
    Return cumulative distribution of hit probability density
    function.
   
    Using the above notation, let G(x)=x Phi (x) + phi (x),
    where Phi(x) is the cumulative distribution function of
    the standard normal distribution and phi(x) the
    probability density function of same

    Then the cumulative distribution function of c is
    F(c) = (G((c + b) / a) - G((c - b) / a)) * a / 2b
    (Willink 2013, Bhattacharjee et al. 1963)

    This cumulative distribution of hit probability density
    yields the probability the hit coordinate is within some
    number of pixels of the ship centerline.

    width - maximum acceptable hit coordinate
    spread - maximum signed arc length of the shot, given
             maximum firing angle and range, representing
             weapon inaccuracy
    standardDeviation - standard deviation of the normal
                        distribution, representing positional error
    """
    left = (width + spread) / standard_deviation
    right = (width - spread) / standard_deviation
    left = left * NormalDist().cdf(left) + NormalDist().pdf(left)
    right = right * NormalDist().cdf(right) + NormalDist().pdf(right)
    return standard_deviation / (2 * spread) * (right - left)

[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 06, 2022, 07:45:53 PM
Yeah, learning Python would probably be a decent idea at some point. Can't wait to see your app. Can you post the final R code you ended up with?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 06, 2022, 08:08:01 PM
Yeah, learning Python would probably be a decent idea at some point. Can't wait to see your app. Can you post the final R code you ended up with?

It's not final but is quite close.  The last sticking points are converting to the class-based arrangement of weapon data and loading CSVs and determining ship width, the latter of which is real trouble because I'm not sure how.

Code

# this is only needed for data visualization - can be safely removed
#library(ggplot2)

# config

#should weapon loadouts be permuted?
PERMUTE_WEAPON_LOADOUTS <- 1

#read or generate lookuptable
GENERATE_LOOKUP_TABLE <- 1

#number of weapons per ship
WEAPON_COUNT <- 8


#constants

#engagementrange
RANGE <- 1000

#fudge factor
ERROR_SD <- 0.05

#the fudge factor should be a function of range (more error in
#position at greater range), but not a function of weapon
#firing angle, and be expressed in terms of pixels
ERROR <- ERROR_SD * RANGE

#default distribution of damage to armor cells
ARMOR_DAMAGE_DISTRIBUTION <- matrix(0,nrow=5,ncol=5)
ARMOR_DAMAGE_DISTRIBUTION[1:5,2:4] <- 1/30
ARMOR_DAMAGE_DISTRIBUTION[2:4,1:5] <- 1/30
ARMOR_DAMAGE_DISTRIBUTION[2:4,2:4] <- 1/15
ARMOR_DAMAGE_DISTRIBUTION[1,1] <- 0
ARMOR_DAMAGE_DISTRIBUTION[1,5] <- 0
ARMOR_DAMAGE_DISTRIBUTION[5,1] <- 0
ARMOR_DAMAGE_DISTRIBUTION[5,5] <- 0

#divide armor rating by this number to obtain armor per cell
ARMOR_FRACTION = 15

#least amount of protection armor affords
MINIMUM_ARMOR_PROTECTION = 0.05

#calculation constant
MINIMUM_CELL_PROTECTION = ARMOR_FRACTION * MINIMUM_ARMOR_PROTECTION

#note: a sample size of 1000 or 10000 would likely be spreadeptable
#in practice, reducing distribution computation time meaningfully.
#I just wanted to go big.
SHOTS <- 100000


#classes


#Holds the data weapon calculations need
Weapon <- setRefClass(
    "Weapon",
    fields = list(
        damage = "numeric",
        shieldDamageFactor = "numeric",
        min_spread = "numeric",
        max_spread = "numeric",
        spread_per_shot = "numeric",
        ticks = "list"
    )
)

#Holds the data ship calculations need
Ship <- setRefClass(
    "Ship",
    fields = list(
        id = "character",
        hull = "numeric",
        shieldEfficiency = "numeric",
        shieldUpkeep = "numeric"
        fluxDissipation = "numeric",
        maxFlux = "numeric",
        armorRating = "numeric",
        widthInPixels = "numeric",
    )
    getArmorGrid <- function() {
        armorPerCell <- .self$armorRating / ARMOR_FRACTION
        return(matrix(armorPerCell, 5, .self$widthInPixels + 4))
    }
)


#functions

getShieldDamageFactor <- function(damageTypeName) {
    if (damageTypeName == "KINETIC") return(2)
    else if (damageTypeName == "HIGH_EXPLOSIVE") return(0.5)
    else if (damageTypeName == "ENERGY") return(1)
    else if (damageTypeName == "FRAGMENATION") return(0.25)
}

#Return a list of the integer shot counts expected
#in each one second interval of one firing cycle.
#
#TODO: Define firing cycle
getTicks <- function(chargeUp,
                     chargeDown,
                     refireDelay,
                     burstDelay,
                     burstSize)
{
    #TODO: Implement
}

#Return a list of all Weapon class instances for a dataframe
getWeapons <- function(weaponData) {
    index <- 1
    weapons <- list()
    for (id in weaponData["id"]) {
        row <- subset(weaponData, name = id)
        weapons[index + 1] <- Weapon(
            damage = as.numeric(row["damage/shot"]),
            shieldDamageFactor = getShieldDamageFactor(as.numeric(
                row["type"])),
            min_spread = as.numeric(row["min spread"])
            max_spread = as.numeric(row["max spread"])
            spread_per_shot = as.numeric(row["spread/shot"])
        )
        index <- 1
    }
    return(weapons)
}

#Return a list of all Ship class instances for a dataframe
getShips <- function(shipData) {
    index <- 1
    ships <- list()
    for (shipId in shipData["id"]) {
        row <- subset(weaponData, name = shipId)
        weapons[index + 1] <- Ship(
            id = shipId,
            hull = as.numeric(row["hitpoints"]),
            shieldEfficiency = as.numeric(row["shield efficiency"]),
            shieldUpkeep = as.numeric(row["shield upkeep"])
            fluxDissipation = as.numeric(row["flux dissipation"]),
            maxFlux = as.numeric(row["max flux"),
            armorRating = as.numeric("armor rating"),
            widthInPixels = #TODO: Implement
        )
        index <- 1
    }
    return(ships)
}

#where does one shot hit within the weapon's arc of fire, in
#pixel difference from the target? get a random angle in
#degrees spreadording to a uniform distribution, then consider
#that the circumference is 2 pi * range pixels, so the hit
#coordinates in pixels are
getShotAngles <- function(spread) {
    return(runif(SHOTS, -spread / 2, spread / 2) * 180 / pi)
}

#now add a random positional error to the coordinates of the hit
getShotErrors <- function() {
    return(rnorm(SHOTS, 0, ERROR))
}

#so which box was hit?
isCellHit <- function(angle, intervalBounds, ship) {
    if (angle < intervalBounds[1]) return(1)
    if (angle > tail(1, intervalBounds)) return(ship[6] + 2)
    left <- 1
    right <- length(intervalBounds)
    while(TRUE) {
        index <- floor((left + right) / 2)
        if (angle <= bounds[index]) right <- index
        else if (angle > bounds[index + 1]) left <- index
        else return(index + 1)
    }
}

#generates the shot distribution per shot
getDistribution <- function(spread) {
    distribution <- vector(mode = "double", length = ship[6] + 2)
    hitLocations <- isCellHit(getShotAngles(spread) + getShotErrors())
    distribution[hitLocations] <- distribution[hitLocations] + 1
    return(distribution / sum(distribution))
}

#generates a sum of matrices multiplied by the distribution
getHitMatrix <- function(spread){
    hits <- matrix(0, 5, ship[6] + 4)
    distribution <- getDistribution(spread)
    for (i in 1:ship[6])
        hits[,i:(i + 4)] <- (hits[,i:(i + 4)]
                             + ARMOR_DAMAGE_DISTRIBUTION
                             * distribution[i + 1])
    return(hits)
}

getHitChance <- function(spread){
    chance <- 0
    distribution <- getDistribution(spread)
    chance <- 1 - distribution[1] + distribution[ship[6]]
    return(chance)
}

#for weapons with damage changing over time we need a sequence of matrices
getHitMatrixSequence <- function(spreadvector){
    hitmatrixsequence <- list()
    for (i in 1:length(spreadvector))
        hitmatrixsequence[] <- getHitMatrix(spreadvector)
    return(hitmatrixsequence)
}

# we do not actually use this function in practice
getArmorDamage <- function(damage, armor, armorRating) {
    factor <- damage / (damage + max(MINIMUM_ARMOR_PROTECTION * armorRating, armor))
    return(damage * (max(0.15, factor)))
}

#this atrociously named function contains a logical switch,
#which probably degrades performance, but also ensures we
#never divide by 0
getArmorDamageSelectiveReduction <- function(damage, armor, armorRating) {
    useMinimumArmor <- 0
    if (armor < MINIMUM_ARMOR_PROTECTION * armorRating / ARMOR_FRACTION) useMinimumArmor <- 1
    if (useMinimumArmor == 0) {
        if(armor == 0) return (damage)
        return(damage * (max(0.15, damage / (damage + armor))))
    }
    factor <- damage / (damage + armorRating * MINIMUM_CELL_PROTECTION)
    return(damage * (max(0.15, )))
}


#how many unique weapon loadouts are there?

#get names of weapons from a choices list x
getWeaponNames <- function(x){
    vector <- vector(mode="character")
    for (i in 1:length(x)) vector <- cbind(vector, x[][[4]])
    return(vector)
}

#convert the names back to numbers when we are done based on a weapon choices list y
convertWeaponNames <- function(x, y){
    vector <- vector(mode="integer")
    for (j in 1:length(x))
        for (i in 1:length(y))
            if(x[j] == y[][[4]]) vector <- cbind(vector, i)
    return(vector)
}

#generates a table of all unique loadouts that we can create
#using the weapon choices available
permuteWeaponLoadouts <- function(weaponChoices) {
    #enumerate weapon choices as integers
 
    perms <- list()
    for (i in 1:WEAPON_COUNT)
        perms[i + length(perms)] <- seq(1, length(weaponChoices), 1)
 
    #create a matrix of all combinations
    perm1x2 <- expand.grid(perms[1],perms[2])
    #sort, then only keep unique rows
    perm1x2 <- unique(t(apply(perm1x2, 1, sort)))
 
    perm3x4 <- expand.grid(perms[3],perms[4])
    perm3x4 <- unique(t(apply(perm3x4, 1, sort)))
 
    perm5x6 <- expand.grid(perms[5],perms[6])
    perm5x6 <- unique(t(apply(perm5x6, 1, sort)))
 
    perm7x8 <- expand.grid(perms[7],perms[8])
    perm7x8 <- unique(t(apply(perm7x8, 1, sort)))
 
    #now that we have all unique combinations of all two weapons,
    #create a matrix containing all combinations of these
    #unique combinations
    allPerms <- matrix(0, 0, (length(perm1x2[1,])
                              + length(perm3x4[1,])
                              + length(perm5x6[1,])
                              + length(perm7x8[1,])))
                             
    for(i in 1:length(perm1x2[,1]))
        for(j in 1:length(perm3x4[,1]))
            for(k in 1:length(perm5x6[,1]))
                for(l in 1:length(perm7x8[,1]))
                    allPerms <- rbind(allPerms, c(perm1x2[i,], perm3x4[j,],
                                      perm5x6[k,], perm7x8[l,])
    )
   
    #we save this so we don't have to compute it again
    saveRDS(allPerms, file="allPerms.RData")
}

#now compute a main lookuptable to save on computing time
#the lookuptable should be a list of lists, so that
#lookuptable[[ship]][[weapon]][[1]] returns hit chance vector and
#lookuptable[[ship]][[weapon]][[2]] returns hit probability matrix
#time for some black R magic

#note: the lookuptable will be formulated such that there is a
#running index of weapons rather than sub-lists, so all weapons
#will be indexed consecutively so we have lookuptable
#[[1]][[1]] = [[ship1]][[weaponchoices1_choice1]], etc.
#So that is what the below section does.
fillLookupTableRow <- function(ship, index) {
    #how much is the visual arc of the ship in rad?
    shipAngle <- ship[5] / (2 * pi * range)
   
    #how much is the visual arc of a single cell of armor in rad?
    cellAngle <- shipAngle / ship[6]
   
    #now assume the weapon is targeting the center of the ship's
    #visual arc and that the ship is in the center of the weapon's
    #firing arc, which cell will the shot hit, or will it miss?
    #call the cells (MISS, cell1, cell2, ... ,celli, MISS) and
    #get a vector giving the (maximum for negative / minimum for
    #positive) angles for hitting each
    angleRange <- vector(mode = "double", length = ship[6] + 1)
    angleRange[1] <- -shipAngle / 2
    for (i in 1:(length(angleRange)-1))
        angleRange[i + 1] <- angleRange + cellAngle
    #now convert it to pixels
    angleRange <- angleRange * 2 * pi * range
       
    weaponIndexMax <- 0
    for (i in 1:WEAPON_COUNT)
        weaponIndexMax <- weaponIndexMax + length(weaponChoices)
       
    for (i in 1:weaponIndexMax)
        for (j in 1:WEAPON_COUNT) {
            a <- 0
            b <- 0
               
            for (k in 1:j) a <- a + length(weaponChoices[k])
            if (j > 1) b <- a - length(weaponChoices[j - 1])
               
            if (i > a & i <= b) next
            weapon <- weaponChoices[j]
               
            if (weapon[4] == "") next
            hitChanceVector <- vector(mode = "double",
                                      length = length(weapon[[5]]))
            for (i in 1:length(weapon[[5]]))
                hitChanceVector <- getHitChance(weapon[[5]])
               
            table[[index]][] <<- list()
            table[[index]][][[1]] <<- hitChanceVector
            table[[index]][][[2]] <<- getHitMatrixSequence(weapon[[5]])
        }
}


generateLookupTable <- function() {
    table <- list()
    for (i in 1:length(ships)) {
        ship <- ships[]
        ship <- as.double(ship[1:6])
        table[] <- getLookupTableEntry(ship)
    }
    # save this so we don't have to re-compute it
    saveRDS(table, file="lookuptable.RData")
}


lookup <- function(ship, weapon, var, table) {
    return(table[[ship]][[weapon]][[var]])
}


#calculate shield damage
#time %% length ... etc. section returns how many shots that
#weapon will fire at that point of it's cycle
#(ie. vector index = time modulo vector index maximum)
 
shieldgetDamageAtTime <- function(weapon, time) {
    nohits <- weapon[[3]][(time %% (length(weapon[[3]]))) + 1]
    if (nohits == 0) return(0)
    else return(weapon[[1]] * nohits)
}


getDamageAtTime <- function(weapon, time, armor, armorRating, x, y, shots) {
    #vectors in R are indexed starting from 1
    #hits[x,y] returns the damage allocation to that cell
    hits <- weapon[[7]][[min(shots, length(weapon[[7]]))]]
    nohits <- weapon[[3]][(time %% (length(weapon[[3]])))+1]
   
    if (nohits == 0) return(0)
    damagesum <- 0
    for (i in 1:nohits) {
        damagesum <- damagesum + getArmorDamageSelectiveReduction(
                     as.double(weapon[[1]] * hits[hitx,hity]), armor,
                     armorRating)
        shots <- shots + 1
        hits <- weapon[[7]][[min(shots, length(weapon[[7]]))]]
    }
    return(damagesum)
}


applyDamage <- function(weapon, factor, shots, shield, armorGrid, hull) {
    factor <- unlist(weapon[2])
    if (shield > getDamageAtTime(weapon, time) * factor){
        shield <- (shield
                  - getDamageAtTime(weapon, time)
                  * factor
                  * weapon[[6]][min(shots, length(weapon[[6]]))])
        shield <- max(shield, 0)
        if(getDamageAtTime(weapon, time) > 0)
            shieldBlock <- 1
    } else {
        #if you did not use shield to block, regenerate flux <- applied later
        #2. armor and hull
        if (unlist(weapon[2]) == 0.25) { factor = 0.25 }
        else { factor = 1 / unlist(weapon[2]) }
       
        #2.1. damage armor and hull
        hulldamage <- 0   
        for (j in 1:length(armorGrid[1,])) {
            for (i in 1:length(armorGrid[,1])) {
                #this monster of a line does the following:
                #hull damage is maximum of: 0, or (weapon damage to armor
                #cell, with multiplier - armor cell hp)/weapon multiplier
                shotDamage <- factor * getDamageAtTime(weapon, time,
                              armorGrid[i, j], armorRating, i, j, shots)

                hulldamage <- hulldamage + max(0, shotDamage - armorGrid[i, j])
                                     
                #new armor hp at cell [i,j] is maximum of: 0, or (armor cell
                #health - weapon damage to that section of armor at that
                #time, multiplied by overall hit probability to hit ship
                #--- note: the damage distribution matrix is calculated such
                #that it is NOT inclusive of hit chance, but hit chance is
                #added separately here
                armorDamage <- shotDamage * weapon[[6]][min(shots,
                                                           length(weapon[[6]]))]
                armorGrid[i, j] <<- max(0, armorGrid[i, j] - armorDamage)
            }
        }
           
        #reduce hull by hull damage times probability to hit ship
        hull <<- hull - hulldamage * weapon[[6]][min(shots,length(weapon[[6]]))]
        hull <<- max(hull, 0)
    }
}


getShotCountIncrease <- function(weapon) {
    weapon[[3]][(time %% (length(weapon[[3]]) + 1))]
}
 
 
getTimeSeries <- function(time,
                          shield,
                          armorHP,
                          hull,
                          shieldRegen,
                          shieldMax,
                          armorRating,
                          armorGrid)
{
    weaponspread <- 0
    shieldBlock <- 0 #are we using shield to block?
    hulldamage <- 0
    if (hull > 0) {} else { shield <- 0 }
       
    for (i in 1:WEAPON_COUNT) {
        applyDamage(weapons, shotCounts, damageFactors,
                    shield, armorGrid, hull)
        shotCounts <- shotCounts + getShotCountIncrease(weapons)
    }
       
    if (hull == 0) armorHP <- 0
       
    armorHP <- sum(armorGrid) * ARMOR_FRACTION / ((ship[[6]] + 4) * 5)
    if (hull == 0) armorHP <- 0
       
    if (shieldBlock == 0) shield <- min(shieldMax, shield + shieldRegen)
   
    return(list(time, shield, armorHP, hull, shieldRegen,
                shieldMax, armorRating, armorGrid))
}


testLoadouts <- function(ship) {
    #format ship data types appropriately
 
    timeSeries <- data.frame(matrix(ncol = 7, nrow = 0))
   
    timeToKill = 0
    shieldBlock <- 0
    totalTime = 500
     
    hull <- ship$hull
    shieldMax <- ship$shieldEfficiency * ship$maxFlux
    shield <- shieldMax
    shieldRegen <- ship$shieldEfficiency
                    * (ship$shieldUpkeep - ship$fluxDissipation)
    armorHP <- ship$armorRating
    armorRating <- ship$armorRating
    armorGrid <- ship$getArmorGrid()
    shotCounts <- list(0, 0, 0, 0, 0, 0, 0, 0)
     
    #go through all the permutations using he running index,
    #which is i+j+k+l+m+n+o+p for weapons 8
    for (z in 1:length(allperms[,1])) {
        weapons <- list()
        for (i in 1:WEAPON_COUNT)
            weapons[i + length(weapons)] <- weaponChoices[[allperms[z,i]]]
       
        for (i in 1:WEAPON_COUNT) {
            if (weapons[4] == "") next
            index <- 0
            for (j in 1:WEAPON_COUNT) index <- index + allperms[z,j]
            weapons[6] <- lookup(f, index, 1)
            weapons[7] <- lookup(f, index, 2)
        }
       
        #time series - run time series at point t, save it to state,
        #update values spreadording to state, re-run time series,
        #break if ship dies
        for (time in 1:totalTime){
            state <- getTimeSeries(time, shield, armorHP, hull, shieldRegen,
                                   shieldMax, armorRating, armorGrid)
            shieldHP <- state[[2]]
            armorHP <- state[[3]]
            hull <- state[[4]]
            flux <- shieldMax - shield
            armor <- state[[8]]
            if(hull == 0) {
                flux <- 0
                if (timeToKill == 0){
                  timeToKill <- time
                  break
                }
            }
        }
        if (timeToKill ==0) timeToKill <- NA
       
        tobind <- c(timeToKill, unlist(weapons[1][4]), unlist(weapons[2][4]),
                                unlist(weapons[3][4]), unlist(weapons[4][4]),
                                unlist(weapons[5][4]), unlist(weapons[6][4]),
                                unlist(weapons[7][4]), unlist(weapons[8][4]))
        timeSeries <- rbind(timeSeries,tobind)
       
        hull <- ship$hull
        armorHP <- ship$armorRating
        armorGrid <- ship$getArmorGrid
        shield <- shieldMax
        shotCounts <- list(0, 0, 0, 0, 0, 0, 0, 0)
        timeToKill <- 0
    }
   
    colnames(timeSeries) <-  c("Timetokill", "Weapon1", "Weapon2",
        "Weapon3", "Weapon4", "Weapon5", "Weapon6", "Weapon7", "Weapon8")
     
    sortbytime <- timeSeries[order(as.integer(timeSeries$Timetokill)),]
     
    write.table(sortbytime, file = paste("optimizeweaponsbytime", ship$id,
                                         "allweaponswithspread.txt", sep = ""),
                row.names = FALSE, sep = "\t")
}


main <- function() {
    config <- read.csv("config")
    weaponData <- read.csv("weapon_data.csv")
    shipData <- read.csv("ship_data.csv")
   
    ships <- list(glimmer, brawlerlp, vanguard, tempest, medusa, hammerhead,
              enforcer, dominator, fulgent, brilliant, radiant, onslaught,
              aurora, paragon, conquest, champion)

    #which weapons are we studying?
    mediumGuns <- list(arbalest, hac, hvd, heavymauler, needler, mortar)
    largeGuns <- list(gauss, markix, mjolnir, hellbore, hephaestus, stormneedler)
    mediumMissiles <- list(harpoon, sabot)
    largeMissiles <- list(squall, locust, hurricane)
   
    weaponChoices <- list(
        mediumGuns,
        mediumGuns,
        largeGuns,
        largeGuns,
        mediumMissiles,
        mediumMissiles,
        largeMissiles,
        largeMissiles
    )
   
    if (PERMUTE_WEAPON_LOADOUTS == 1) permuteWeaponLoadouts(weaponChoices)
    else allPerms <- readRDS("lookuptable.RData")
   
    if (GENERATE_LOOKUP_TABLE == 1) generateLookupTable()
    else table <- readRDS("lookuptable.RData")
   
    #go through all ships
    for (f in 1:length(ships)) testLoadouts(ships[f])
}

main()



#use computationally inexpensive functions to manage

shipnames <- c("glimmer","brawlerlp","vanguard","tempest","medusa",
               "hammerhead","enforcer","dominator","fulgent","brilliant",
               "radiant","onslaught","aurora","paragon","conquest",
               "champion")

#get a filename
filename <- function(x) {
    paste("optimizeweaponsbytime",
          shipnames
  • ,

          "allweaponswithacc.txt",
          sep = "")
}


#read a file
readfile <- function(filename) {
    read.csv(filename, sep = "\t")
}


#methods to get data tables

getFormattedTTKTable <- function(table) {
    table <- cbind(table, (1 / (table$Time / mean(table$Time)) - 1))
    table[,3] <- sprintf("%0.1f%%", table[,3] * 100)
    table <- table[order(table$Time),]
    table[,2] <- round(table[,2], digits=1)
    return(table)
}


getTTKTable <- function(dataFrame, time, label) {
    table <- aggregate(time, data = dataFrame, FUN = mean, na.rm = TRUE)
    table <- getFormattedTTKTable(table)
    colnames(table) <- c(label, "Avg. time to kill", "TTK speed score")
    return(table)
}


getGunAndLargeMissileTable <- function(dataFrameSorted) {
    table <- aggregate(Time ~ Largemissiles + Largeguns + Mediumguns,
                       data = dataFrameSorted,
                       FUN = mean,
                       na.rm = TRUE)
                       
    table <- getFormattedTTKTable(table)
   
    colnames(table) <- c("Large missiles",
                         "Large guns",
                         "Medium guns",
                         "Avg. time to kill",
                         "TTK speed score")
                         
    return(table)
}


#method to write data tables to the screen
writeTable <- function(table, filename) {
    write.table(table, file = filename, row.names = FALSE, sep = "/t")
}


getDataFrame <- function(data) {
    dataFrame <- data.frame()
   
    for (i in 1:length(shipnames)) {
        shipnamecolumn <- matrix(data = shipnames, ncol = 1, nrow = 7938)
        mainDataFrame <- rbind(dataFrame,
                               cbind(shipnamecolumn, readfile(filename(i))))
    }

    colnames(dataFrame) <- c("Ship",
                             "Time",
                             "Largemissile1",
                             "Largemissile2",
                             "Mediummissile1",
                             "Mediummissile2",
                             "Largegun1",
                             "Largegun2",
                             "Mediumgun1",
                             "Mediumgun2")
   
    return(dataFrame)
}


getDataFrameSorted <- function(data, dataFrame) {
    dataFrameSorted <- data.frame()
   
    #order large missiles, medium missiles, and large guns
    #alphabetically by row and merge cells
    for (i in 1:length(dataFrame[,1])){
        largeMissiles <- paste(t(apply(dataFrame[i,3:4], 1, sort))[2],
                               t(apply(dataFrame[i,3:4], 1, sort))[1],
                               sep = " ")
        mediummissiles <- paste(t(apply(maindataframe[i,5:6], 1, sort))[2],
                                t(apply(maindataframe[i,5:6], 1, sort))[1],
                                sep = " ")
        largeGuns <- paste(t(apply(dataFrame[i,7:8], 1, sort))[1],
                           t(apply(dataFrame[i,7:8], 1, sort))[2], sep = " ")
        mediumGuns <- paste(t(apply(dataFrame[i,9:10], 1, sort))[1],
                            t(apply(dataFrame[i,9:10], 1, sort))[2], sep = " ")
        toBind <- c(dataFrame[i,1], dataFrame[i,2], largeMissiles, largeGuns,
                    mediumMissiles, mediumGuns)
        dataFrameSorted <- rbind(dataFrameSorted, toBind)
    }
   
    colnames(dataFrameSorted) <- c("Ship",
                                   "Time",
                                   "Largemissiles",
                                   "Largeguns",
                                   "Mediumguns")
                                       
    dataFrameSorted$Time <- as.double(dataFrameSorted$Time)
   
    return(dataFrameSorted)
}


#analysis with large missiles set at squallx
#(squall,locust,hurricane), large guns, and medium guns
analyze <- function(data) {

    #range with itu + bm + gi
    #(+85 total for ballistic & energy, +0 for missiles)
    gunsRangeMult <- 1.85
   
    mediumGuns <- read.csv("medium_guns.csv")
    largeGuns <- read.csv("large_guns.csv")
    mediumMissiles <- read.csv("medium_missiles.csv")
    largeMissiles <- read.csv("large_missiles.csv")
   
    dataFrame <- getDataFrame(data)
    dataFrameSorted <- getDataFrameSorted(data, dataFrame)
   
    #assemble tables
    shipTable <- getTKKTable(dataFrameSorted, Time ~ Ship, "Ship")             
    mediumGunTable <- getTTKTable(dataFrame, Time ~ mediumGuns, "Medium Gun")
    largeGunTable <- getTTKTable(dataFrame, Time ~ largeGuns, "Large Gun")
    mediumMissileTable <- getTTKTable(dataFrame, Time ~ mediumMissile,
                                      "Medium Missile")
    largeMissileTable <- getTTKTable(dataFrame, Time ~ largeMissile,
                                     "Large Missile")
    gunAndLargeMissilesTable <- getGunAndLargeMissileTable(dataFrameSorted)
   
    return(list(shipTable, "shipTable.txt",
                mediumGunTable, "mediumGunTable3.txt",
                largeGunTable, "largeGunTable3.txt",
                mediumMissileTable, "mediumMissileTable3.txt",
                largeMissileTable, "largeMissileTable3.txt",
                gunAndLargeMissilesTable, "gunAndLargeMissileTable.txt"))
}


main <- function() {
    tablesAndNames <- analyze(data)
    for (i in seq(1,tablesAndNames.length,2))
        writeTable(tablesAndNames, tablesAndNames[i+1])
}

main()



#Weapon
#id,min spread,max spread,spread per shot
"squall",0,0,0
"locust",0,0,0
"hurricane",0,0,0
"harpoon",0,0,0
"sabot",0,0,0
"gauss",0,0,0
"hephaestus",0,10,2
"markix",0,15,2
"mjolnir",0,5,1
"hellbore",10,10,0
"stormneedler",10,10,0
"arbalest",0,5,10
"heavymaul",0,5,1
"hac",0,18,3
"hvd",0,0,0
"needler",1,10,0.5
"mortar",0,20,5

#tics - generate this from ammo, burst size, burst delay, and refire delay
"squall",2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0
"locust",10,10,10,10,0,0,0,0,0
"hurricane",9,0,0,0,0,0,0,0,0,0,0,0,0,0,0
"harpoon",4,0,0,0,0,0,0,0,0
"sabot",10,0,0,0,0,0,0,0,0
"gauss",1,0
"hephaestus",4
"markix",4,0,0,4,0,0,4,0,0,4,0,0,0,4,0,0,0
"mjolnir",1,1,2,1,2,1,2,1,1
"hellbore",1,0,0,0
"stormneedler",10
"arbalest",1,1,1,0,1,1
"heavymauler",3,0,0,0,0,0,3,0,0,0,0,3,0,0,0,0,3,0,0,0,0,3,0,0,0,0,0
"hac",3,0,3,3,0,3,3,0
"hvd",1,0,0
"needler",20,10,0,0,0,0
"mortar",2,2,0,2,2,2,2,0,2,2,2,0,2




#damage per shot,damage type (2=kinetic,0.5=he,0.25=frag,1=energy)
"arbalest",200,2,
"heavymauler",200,0.5,
"hac",100,2,
"hvd",275,2,
"needler",50,2,
"mortar",110,0.5,
"squall",250,2,
"locust",200,0.25,
"hurricane",500,0.5,
"harpoon",750,0.5,
"sabot",200,2,
"gauss",700,2,
"hephaestus",120,0.5,
"markix",200,2,
"mjolnir",400,1,
"hellbore",750,0.5,
"stormneedler",50,2



#ships -
#ship, hullhp, shieldRegen, shieldMax, startingArmor, widthinpixels, armorcells, name
glimmer <- c(1500,250/0.6,2500/0.6,200,78,5,"glimmer")
brawlerlp <- c(2000,500/0.8,3000/0.8,450,110,floor(110/15),"brawlerlp")
vanguard <- c(3000,150,2000,600,104,floor(104/15),"vanguard")
tempest <- c(1250,225/0.6,2500/0.6,200,64,floor(64/15),"tempest")
medusa <- c(3000,400/0.6,6000/0.6,300,134,floor(134/15),"medusa")
hammerhead <- c(5000,250/0.8,4200/0.8,500,108,floor(108/16.4),"hammerhead")
enforcer <- c(4000,200,4000,900,136,floor(136/15), "enforcer")
dominator <- c(14000,500,10000,1500,220,12,"dominator")
fulgent <- c(5000,300/0.6,5000/0.6,450,160,floor(160/15), "fulgent")
brilliant <- c(8000,600/0.6,10000/0.6,900,160,floor(160/20),"brilliant")
radiant <- c(20000,1500/0.6,25000/0.6,1500,316,floor(316/30),"radiant")
onslaught <- c(20000,600,17000,1750,288,floor(288/30),"onslaught")
aurora <- c(8000,800/0.8,11000/0.8,800,128,floor(128/28), "aurora")
paragon <- c(18000,1250/0.6,25000/0.6,1500,330,floor(330/30),"paragon")
conquest <- c(12000,1200/1.4,20000/1.4,1200,190,floor(190/30),"conquest")
champion <- c(10000,550/0.8,10000/0.8,1250,180,floor(180/24),"champion")

[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 06, 2022, 08:31:50 PM
Thanks! It's way more professional looking than mine. I don't know how to do this in R, but you could just look at the ship's sprite and use the width of the image? It's what I did. I'm sure the actual collision boundaries are close enough.

By the way I noticed an error in the plaintext still, in
    "This cumulative distribution of hit probability density
    yields the probability the hit coordinate is within some
    number of pixels of the ship centerline."

Of course, the CDF yields the probability that the hit coordinate is less than X. What the text refers to would be -2(CDF(-|X|)-CDF(0))=1-2CDF(-|X|) for the symmetrical distribution we have. Or CDF(|X|)-CDF(-|X|) for the general case. But if that's meant to convey the idea to non technical people that's fine.

Incidentally you might want to compute hit chance as just 1-2CDF(-shipwidth/2). And the probability to hit a cell is of course CDF(upperbound)-CDF(lowerbound). If we are using this method then the probability to hit cell is inclusive of probability to hit ship, so the part about re-applying prob to hit should be removed - it is included in the damage fraction to cell. However, hit chance is still needed for the damage to shields part where we don't use cells.

Like shielddamage = rawdamage*mult*hitprob
Armordamagetocell = rawdamage*celldistributionmultiplier*mult
Celldistributionmultiplier=matrix[i,j] where matrix = sum(i from 1 to shipcells)((CDF(upperbound_celli)-CDF(lowerbound_celli))*5x5damagedistributionmatrix)
Damagetohull = sum(over cells)(max(0,(armordamagetocell-armorhp)/mult)))

in pseudocode
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 07, 2022, 04:46:49 AM
Thanks! It's way more professional looking than mine. I don't know how to do this in R, but you could just look at the ship's sprite and use the width of the image? It's what I did. I'm sure the actual collision boundaries are close enough.

Or I could loop through the collision boundary points in the .ship file to find the left-and right-most ones.

Quote
By the way I noticed an error in the plaintext still, in
    "This cumulative distribution of hit probability density
    yields the probability the hit coordinate is within some
    number of pixels of the ship centerline."

Of course, the CDF yields the probability that the hit coordinate is less than X. What the text refers to would be -2(CDF(-|X|)-CDF(0))=1-2CDF(-|X|) for the symmetrical distribution we have. Or CDF(|X|)-CDF(-|X|) for the general case. But if that's meant to convey the idea to non technical people that's fine.

I am confused about why you chose to determine whether the coordinate is less than X: what does X represent?  :o

Quote
Incidentally you might want to compute hit chance as just 1-2CDF(-shipwidth/2). And the probability to hit a cell is of course CDF(upperbound)-CDF(lowerbound). If we are using this method then the probability to hit cell is inclusive of probability to hit ship, so the part about re-applying prob to hit should be removed - it is included in the damage fraction to cell. However, hit chance is still needed for the damage to shields part where we don't use cells.

Those equations and that idea look much cleaner!

Quote
Like shielddamage = rawdamage*mult*hitprob
Armordamagetocell = rawdamage*celldistributionmultiplier*mult
Celldistributionmultiplier=matrix[i,j] where matrix = sum(i from 1 to shipcells)((CDF(upperbound_celli)-CDF(lowerbound_celli))*5x5damagedistributionmatrix)
Damagetohull = sum(over cells)(max(0,(armordamagetocell-armorhp)/mult)))

in pseudocode

Here are my changes and commented questions.


shield_damage = raw_damage * mult * hitprob

damage_distribution_matrix = #what is the damage distribution matrix?

#the name of this variable should state what kind of matrix it represents
#also, I don't know why a sum is in this equation—would you please explain?
#the code already can determine cell size, right?
matrix = sum(i from 1 to shipcells)((CDF(upperbound_cell_i) - CDF(lowerbound_cell_i)) * damage_distribution_matrix)

cell_distribution_multiplier= matrix[i,j]

cell_damage = raw_damage * cell_distribution_multiplier * mult

#same question about the sum here
#what is the mult?
hull_damage = sum(over cells)(max(0, (cell_damage - armorhp) / mult)))
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 07, 2022, 08:20:12 AM
Thanks! It's way more professional looking than mine. I don't know how to do this in R, but you could just look at the ship's sprite and use the width of the image? It's what I did. I'm sure the actual collision boundaries are close enough.

Or I could loop through the collision boundary points in the .ship file to find the left-and right-most ones.

Good idea, an improvement definitely.

Quote
I am confused about why you chose to determine whether the coordinate is less than X: what does X represent?  :o


X is the horizontal coordinate. Perhaps this illustration will explain better than words (attachment).

Quote
Here are my changes and commented questions.


shield_damage = raw_damage * mult * hitprob

damage_distribution_matrix = #what is the damage distribution matrix?
It is the matrix
0    1/30 1/30 1/30    0
1/30 1/15 1/15 1/15 1/30
1/30 1/15 1/15 1/15 1/30
1/30 1/15 1/15 1/15 1/30
0    1/30 1/30 1/30    0
that we use to distribute damage to armor cells (ie. that Starsector uses to pool armor)



#the name of this variable should state what kind of matrix it represents
#also, I don't know why a sum is in this equation%u2014would you please explain?
#the code already can determine cell size, right?
matrix = sum(i from 1 to shipcells)((CDF(upperbound_cell_i) - CDF(lowerbound_cell_i)) * damage_distribution_matrix)

This means the (shipcells+4) x 5] matrix that stores the ship's expected armor state.
Now imagine the central shipcells x 1 cells that are the actual armor cells on the ship.
Then we get the damage distribution to the (shipcells+4) x 5 matrix by a partially overlapping sum of the damage matrix,
where we apply (ie. subtract) the damage matrix to the (shipcells+4) x 5 matrix for each central cell, centered at the central cell,
where each summand damage matrix is multiplied by the probability to hit that armor cell.
This is essentially the main thing that we are doing in this model.
It is the thing illustrated in the damage distribution illustration in the OP.

To make it easier to imagine, visualize this:
a wave of ghost bullets whose intensity ("reality") is equivalent to the shot hit probability
at that location hits the ship. They each hit similarly transparent matrices of armor cells.
 What is the damage taken by the ships' armor from this wave? it is the (shipcells+4)x5 matrix
where for each of the central armor cells hit, the entire matrix takes
hit probability * damage distribution matrix * damage damage in the 5 x 5 area centered at the hit location.
The central assumption of the model might be taken to mean that this is a valid way of computing expected damage.


cell_distribution_multiplier= matrix[i,j]

cell_damage = raw_damage * cell_distribution_multiplier * mult

#same question about the sum here
#what is the mult?
in this pseudocode it is the damage type multiplier e.g. kinetic, he etc.

hull_damage = sum(over cells)(max(0, (cell_damage - armorhp) / mult)))


[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 07, 2022, 09:22:48 AM
Good idea, an improvement definitely.

Thanks, will do!

Quote
X is the horizontal coordinate. Perhaps this illustration will explain better than words (attachment).

Oooooh, I now understand that you mean x to be the distance from the left of the ship to the right.  Now, if the weapon is along the extended centerline of the ship, and if the x coordinate of the hit (without spread) is roughly the arc length of the angle between that centerline and the aim line of the weapon, then I think x should be the distance from the centerline, but you did not write that, and yet the code worked just as I imagine it would if you had.  Why is that?  :o

Quote
Here are my changes and commented questions.


shield_damage = raw_damage * mult * hitprob

damage_distribution_matrix = #what is the damage distribution matrix?
It is the matrix
0    1/30 1/30 1/30    0
1/30 1/15 1/15 1/15 1/30
1/30 1/15 1/15 1/15 1/30
1/30 1/15 1/15 1/15 1/30
0    1/30 1/30 1/30    0
that we use to distribute damage to armor cells (ie. that Starsector uses to pool armor)



Sweet relief.  To the comments it goes!  Now, uh, where should I put all this, again?  ???  And speaking of damage calculation code, I still have two methods that refer to SHOTS, but we are doing things analytically now.  I wonder what to do with them.  Please excuse the half-finished transition to Python.


def shot_angles(spread):
    """
    Return where a shot hits within the weapon's firing arc in
    pixel difference from the target?
   
    Get a random angle in degrees spread over to a uniform
    distribution, then consider that the circumference is 2 pi *
    range pixels, so the hit coordinates in pixels are
    """
    return runif(SHOTS, -spread / 2, spread / 2) * 180 / pi)


def shot_errors():
    """Random positional error to the coordinates of the hits."""
    return rnorm(SHOTS, 0, ERROR)


Quote

#the name of this variable should state what kind of matrix it represents
#also, I don't know why a sum is in this equation%u2014would you please explain?
#the code already can determine cell size, right?
matrix = sum(i from 1 to shipcells)((CDF(upperbound_cell_i) - CDF(lowerbound_cell_i)) * damage_distribution_matrix)

This means the (shipcells+4) x 5] matrix that stores the ship's expected armor state.
Now imagine the central shipcells x 1 cells that are the actual armor cells on the ship.
Then we get the damage distribution to the (shipcells+4) x 5 matrix by a partially overlapping sum of the damage matrix,
where we apply (ie. subtract) the damage matrix to the (shipcells+4) x 5 matrix for each central cell, centered at the central cell,
where each summand damage matrix is multiplied by the probability to hit that armor cell.
This is essentially the main thing that we are doing in this model.
It is the thing illustrated in the damage distribution illustration in the OP.

Wait, why are there two matrices of different sizes?  My head hurts.  :(

Quote
To make it easier to imagine, visualize this:
a wave of ghost bullets whose intensity ("reality") is equivalent to the shot hit probability
at that location hits the ship. They each hit similarly transparent matrices of armor cells.
 What is the damage taken by the ships' armor from this wave? it is the (shipcells+4)x5 matrix
where for each of the central armor cells hit, the entire matrix takes
hit probability * damage distribution matrix * damage damage in the 5 x 5 area centered at the hit location.
The central assumption of the model might be taken to mean that this is a valid way of computing expected damage.[/b]

Ahhhh, just as I imagined.  So, we should write


apply_damage(amount, armor_grid, probability_distribution, damage_distribution):
    for i, row in enumerate(cell_damage):
        for j, cell in enumerate(row):
            armor_grid[j] -= probability_distribution[j]
                             * damage_distribution[j]
                             * hit_damage

apply_damage(shot_damage / shield_damage_factor, armor_grid,
             probability_distribution, damage_distribution)


Quote
cell_distribution_multiplier= matrix[i,j]

cell_damage = raw_damage * cell_distribution_multiplier * mult

#same question about the sum here
#what is the mult?
in this pseudocode it is the damage type multiplier e.g. kinetic, he etc.

hull_damage = sum(over cells)(max(0, (cell_damage - armorhp) / mult)))
[/pre]

Ahhhhh, ok, now I get it.

Python optimization code so far.  Next step is splitting it into smaller files.

Spoiler

from statistics import NormalDist
from math import pi

# config

#should weapon loadouts be permuted?
PERMUTE_WEAPON_LOADOUTS = 1

#read or generate lookuptable
GENERATE_LOOKUP_TABLE = 1

#number of weapons per ship
WEAPON_COUNT = 8


#constants

#engagementrange
RANGE = 1000

#fudge factor
ERROR_SD = 0.05

#the fudge factor should be a  of range (more error in
#position at greater range), but not a  of weapon
#firing angle, and be expressed in terms of pixels
ERROR = ERROR_SD * RANGE

#default distribution of damage to armor cells
ARMOR_DAMAGE_DISTRIBUTION = matrix(0,nrow=5,ncol=5)
ARMOR_DAMAGE_DISTRIBUTION[range(5,2:4] = 1/30
ARMOR_DAMAGE_DISTRIBUTION[2:4,range(5] = 1/30
ARMOR_DAMAGE_DISTRIBUTION[2:4,2:4] = 1/15
ARMOR_DAMAGE_DISTRIBUTION[1,1] = 0
ARMOR_DAMAGE_DISTRIBUTION[1,5] = 0
ARMOR_DAMAGE_DISTRIBUTION[5,1] = 0
ARMOR_DAMAGE_DISTRIBUTION[5,5] = 0

#multiply armor rating by this number to obtain armor per cell
ARMOR_FRACTION = 1 / 15

#least amount of protection armor affords
MINIMUM_ARMOR_PROTECTION = 0.05

#calculation constant
MINIMUM_CELL_PROTECTION = ARMOR_FRACTION * MINIMUM_ARMOR_PROTECTION

#note: a sample size of 1000 or 10000 would likely be spreadeptable
#in practice, reducing distribution computation time meaningfully.
#I just wanted to go big.
SHOTS = 100000


SHIELD_DAMAGE_FACTORS = Dict("KINETIC" => 2,
                             "HIGH_EXPLOSIVE” => 0.5,
                             "ENERGY" => 1,
                             "FRAGMENATION” => 0.25)

#classes

class LookupTable:
    def __init__(self, ships):
        self._table = []
        for i in range(len(ships):
            ship = ships[]
            ship = as.double(ship[range(6])
            self._table[] = getLookupTableEntry(ship)

    def lookup(self, ship, weapon, var):
        return self._table[[ship]][[weapon]][[var]]


class Weapon:
    """Holds the data weapon calculations need."""
    def __init__(self, csv_row):
        damage =
        shield_damage_factor =
        min_spread =
        max_spread =
        spread_per_shot =
        ticks =


class Ship:
    """Holds the data ship calculations need."""
    def __init__(self, csv_row):
        id = ,
        hull = ,
        shield_efficiency = ,
        shield_upkeep =
        flux_dissipation = ,
        max_flux = ,
        armor_rating = ,
        width_in_pixels = ,

    def armor_grid(self):
        armorPerCell = self.armor_rating * ARMOR_FRACTION
        return matrix(armorPerCell, 5, self.width_in_pixels + 4))
   

def weapon_ticks(chargeUp,
                 chargeDown,
                 refireDelay,
                 burstDelay,
                 burstSize):
    """
    Return a list of the integer shot counts expected
    in each one second interval of one firing cycle.
   
    TODO: Define firing cycle
    """
    #TODO: Implement
    pass


def weapon_list[weapon_data):
    """Return a list of all Weapon class instances for a dataframe"""
    index = 0
    weapons = []
    for id in weapon_data["id"])
        row = subset(weapon_data, name = id)
        weapons[index + 1] = Weapon(
            damage = as.numeri[row["damage/shot"]),
            shieldDamageFactor = shield_damage_factor(as.numeri[
                row["type"])),
            min_spread = as.numeri[row["min spread"])
            max_spread = as.numeri[row["max spread"])
            spread_per_shot = as.numeri[row["spread/shot"])
        )
        index = 0
    return weapons


def ship_list(ship_data):
    """Return a list of all Ship class instances for a dataframe."""
    return [Ship(subset(weapon_data, name = shipId))
            for shipId in ship_data["id"]]


def shot_angles(spread):
    """
    Return where a shot hits within the weapon's firing arc in
    pixel difference from the target?
   
    Get a random angle in degrees spread over to a uniform
    distribution, then consider that the circumference is 2 pi *
    range pixels, so the hit coordinates in pixels are
    """
    return runif(SHOTS, -spread / 2, spread / 2) * 180 / pi)



def shot_errors():
    """Random positional error to the coordinates of the hits."""
    return rnorm(SHOTS, 0, ERROR)


def isCellHit(angle, interval_bounds, ship):
    """Return whether the box was hit."""
    if angle < interval_bounds[1]: return 1
    if angle > tail(1, interval_bounds): return ship[6] + 2
    left, right = 1, len(interval_bounds)
    while(True):
        index = (left + right) // 2
        if angle <= bounds[index]: right = index
        elif angle > bounds[index + 1]: left = index
        else: return index + 1
   

def shotDistribution(spread):
    """Return shot distribution per shot."""
    distribution = vector(mode = "double", len = ship[6] + 2)
    hitLocations = isCellHit(shot_angles(spread) + shot_errors():)
    distribution[hitLocations] = distribution[hitLocations] + 1
    return distribution / sum(distribution


def hitMatrix(spread):
    """A sum of matrices multiplied by the distribution."""
    hits = matrix(0, 5, ship[6] + 4)
    distribution = getDistribution(spread)
    for i in range(ship[6])
        hits[,i:(i + 4)](hits[,i:(i + 4)]
                             + ARMOR_DAMAGE_DISTRIBUTION
                             * distribution[i + 1])
    return hits


def hitChance(spread):
    distribution = getDistribution(spread)
    return 1 - distribution[1] + distribution[ship[6]]


def hit_matrix_sequence(spreadvector):
    """
    Return a sequence of matrices for a weapons with damage
    changing over time.
    """
    hitmatrixsequence = []
    for i in range(len(spreadvector))
        hitmatrixsequence[] = getHitMatrix(spreadvector)
    return hitmatrixsequence


# we do not actually use this  in practice
def armor_damage(damage, armor, armor_rating):
    factor = 1 / (1 + max(MINIMUM_ARMOR_PROTECTION * armor_rating, armor) / damage)
    return damage * max(0.15, factor))


def armor_damage_selective_reduction(damage, armor, armor_rating):
    """
    This atrociously named function contains a logical switch,
    which probably degrades performance, but also ensures we
    never divide by 0
    """
    minimum_armor = MINIMUM_ARMOR_PROTECTION * armor_rating * ARMOR_FRACTION
    if armor < minimum_armor:
        if armor == 0: return damage
        return damage * (max(0.15, damage / (damage + armor))))
   
    factor = 1 / (1 + armor_rating * MINIMUM_CELL_PROTECTION / damage)
    return damage * (max(0.15, )))



#how many unique weapon loadouts are there?


def weapon_names(x):
    """Return names of weapons from a choices list x."""
    vector = vector(mode=)
    for i in range(len(x)) vector = cbind(vector, x[][[4]])
    return vector


def convert_weapon_names(x, y):
    """
    Convert the names back to numbers when we are done based
    on a weapon choices list y.
    """
    vector = vector(mode="integer")
    for j, _ in enumerate(x):
        for i, _ in enumerate(y):
            if x[j] == y[][[4]] vector = cbind(vector, i)
    return vector


def permute_weapon_loadouts(weaponChoices):
    """
    Return a table of all unique loadouts that we can create
    using the weapon choices available.
    """
    #enumerate weapon choices as integers
 
    perms = [seq(1, len(weaponChoices), 1) for i in range(WEAPON_COUNT)]
 
    #create a matrix of all combinations
    perm1x2 = expand.grid(perms[1],perms[2])
    #sort, then only keep unique rows
    perm1x2 = unique(t(apply(perm1x2, 1, sort)))
 
    perm3x4 = expand.grid(perms[3],perms[4])
    perm3x4 = unique(t(apply(perm3x4, 1, sort)))
 
    perm5x6 = expand.grid(perms[5],perms[6])
    perm5x6 = unique(t(apply(perm5x6, 1, sort)))
 
    perm7x8 = expand.grid(perms[7],perms[8])
    perm7x8 = unique(t(apply(perm7x8, 1, sort)))
 
    #now that we have all unique combinations of all two weapons,
    #create a matrix containing all combinations of these
    #unique combinations
    all_perms = matrix(0, 0, (len(perm1x2[1,])
                              + len(perm3x4[1,])
                              + len(perm5x6[1,])
                              + len(perm7x8[1,])))
                             
    for i, _ in enumerate(perm1x2[,1]):
        for j, _ in enumerate(perm3x4[,1]):
            for k, _ in enumerate(perm5x6[,1]):
                for l, _ in enumerate(perm7x8[,1]):
                    all_perms = rbind(all_perms, [perm1x2[i,], perm3x4[j,],
                                      perm5x6[k,], perm7x8[l,])
   
    #we save this so we don't have to compute it again
    saveRDS(all_perms, file="all_perms.RData")


def fill_lookup_table_row(ship, index):
    """
    now compute a main lookuptable to save on computing time
    the lookuptable should be a list of lists, so that
    lookuptable[[ship]][[weapon]][[1]] returns hit chance vector and
    lookuptable[[ship]][[weapon]][[2]] returns hit probability matrix
    time for some black R magic

    note: the lookuptable will be formulated such that there is a
    running index of weapons rather than sub-lists, so all weapons
    will be indexed consecutively so we have lookuptable
    [[1]][[1]] = [[ship1]][[weaponchoices1_choice1]], etc.
    So that is what the below section does.
    """

    #how much is the visual arc of the ship in rad?
    ship_angle = ship[5] / (2 * pi * distance)
   
    #how much is the visual arc of a single cell of armor in rad?
    cell_angle = ship_angle / ship[6]
   
    #now assume the weapon is targeting the center of the ship's
    #visual arc and that the ship is in the center of the weapon's
    #firing arc, which cell will the shot hit, or will it miss?
    #call the cells (MISS, cell1, cell2, ... ,celli, MISS) and
    #get a vector giving the (maximum for negative / minimum for
    #positive) angles for hitting each
    angle_range = vector(mode = "double", len = ship[6] + 1)
    angle_range[1] = -ship_angle / 2
    for i in range(ship[6]):
        angle_range[i + 1] = angleRange + cell_angle
    #now convert it to pixels
    angle_range *= 2 * pi * distance
       
    weaponIndexMax = 0
    for i in range(WEAPON_COUNT):
        weaponIndexMax = weaponIndexMax + len(weaponChoices)
       
    for i in range(weaponIndexMax):
        for j in range(WEAPON_COUNT):
            a, b = 0, 0
               
            for k in range(j) a = a + len(weaponChoices[k])
            if j > 1: b = a - len(weaponChoices[j - 1])
               
            if i > a & i <= b: continue
            weapon = weaponChoices[j]
               
            ifweapon[4] == "") continue
            hitChanceVector = vector(mode = "double",
                                     len = len(weapon[[5]]))
            for i, _ in enumerate(weapon[[5]])
                hitChanceVector = getHitChance(weapon[[5]])
               
            table[[index]][] <= []
            table[[index]][][[1]] <= hitChanceVector
            table[[index]][][[2]] <= getHitMatrixSequence(weapon[[5]])
       

def shieldDamageAtTime(weapon, time):
    """
    Calculate shield damage.

    time %% len ... etc. section returns how many shots that
    weapon will fire at that point of it's cycle
    (ie. vector index = time modulo vector index maximum)
    """
    nohits = weapon[[3]][(time %% (len(weapon[[3]]))) + 1]
    if nohits == 0: return 0)
    else: return weapon[[1]] * nohits


def damage_at_time(weapon, time, armor, armor_rating, x, y, shots):
    #vectors in R are indexed starting from 1
    #hits[x,y] returns the damage allocation to that cell
    hits = weapon[[7]][[min(shots, len(weapon[[7]]))]]
    nohits = weapon[[3]][(time %% (len(weapon[[3]])))+1]
   
    if nohits == 0: return 0
    damagesum = 0
    for i in range(nohits)
        damagesum = damagesum + armor_damage_selective_reduction(
                     as.double(weapon[[1]] * hits[hitx,hity]), armor,
                     armor_rating)
        shots = shots + 1
        hits = weapon[[7]][[min(shots, len(weapon[[7]]))]]
   
    return damagesum


def apply_damage(weapon, factor, shots, shield, armor_grid, hull):
    factor = un[weapon[2])
    if shield > damage_at_time(weapon, time) * factor:
        shield(shield
               - damage_at_time(weapon, time)
               * factor
               * weapon[[6]][min(shots, len(weapon[[6]]))])
        shield = max(shield, 0)
        if(damage_at_time(weapon, time) > 0)
            shield_blocking = 1
     else:
        #if you did not use shield to block, regenerate flux = applied later
        #2. armor and hull
        if [weapon[2] == 0.25: factor = 0.25 
        else: factor = 1 / un[weapon[2])
       
        #2.1. damage armor and hull
        hull_damage = 0   
        for j in range(len(armor_grid[1,]))
            for i in range(len(armor_grid[,1]))
                #this monster of a line does the following:
                #hull damage is maximum of: 0, or (weapon damage to armor
                #cell, with multiplier - armor cell hp)/weapon multiplier
                shotDamage = factor * damage_at_time(weapon, time,
                              armor_grid[i, j], armor_rating, i, j, shots)

                hull_damage = hull_damage + max(0, shotDamage - armor_grid[i, j])
                                     
                #new armor hp at cell [i,j] is maximum of: 0, or (armor cell
                #health - weapon damage to that section of armor at that
                #time, multiplied by overall hit probability to hit ship
                #--- note: the damage distribution matrix is calculated such
                #that it is NOT inclusive of hit chance, but hit chance is
                #added separately here
                armorDamage = shotDamage * weapon[[6]][min(shots,
                                                           len(weapon[[6]]))]
                armor_grid[i, j] <= max(0, armor_grid[i, j] - armorDamage)
           
       
           
        #reduce hull by hull damage times probability to hit ship
        hull <= hull - hull_damage * weapon[[6]][min(shots,len(weapon[[6]]))]
        hull <= max(hull, 0)
   

def shot_count_increase(weapon):
    weapon[[3]][(time %% (len(weapon[[3]]) + 1))]

 
def time_series(time,
                shield,
                armor_hp,
                hull,
                shield_regen,
                shield_max,
                armor_rating,
                armor_grid):

    spread = 0
    shield_blocking = False
    hull_damage = 0
    if hull <= 0: shield = 0
       
    for i in range(WEAPON_COUNT):
        apply_damage(weapons, shot_counts, damageFactors,
                     shield, armor_grid, hull)
        shot_counts += shot_count_increase(weapons)
   
    if hull == 0: armor_hp = 0
       
    armor_hp = sum(armor_grid) * ARMOR_FRACTION / ((ship[[6]] + 4) * 5)
    if hull == 0: armor_hp = 0
       
    if not shield_blocking: shield = min(shield_max, shield + shield_regen)
   
    return [time, shield, armor_hp, hull, shield_regen,
            shield_max, armor_rating, armor_grid]


def test_loadout(ship):
    #format ship data types appropriately
 
    timeSeries = data.frame(matrix(ncol = 7, nrow = 0))
   
    time_to_kill = 0
    shield_blocking = 0
    total_time = 500
     
    hull = ship.hull
    shield_max = ship.shield_efficiency * ship.max_flux
    shield = shield_max
    shield_regen = ship.shield_efficiency
                    * (ship.shield_upkeep - ship.flux_dissipation)
    armor_hp = ship.armor_rating
    armor_rating = ship.armor_rating
    armor_grid = ship.getarmor_grid()
    shot_counts = [0, 0, 0, 0, 0, 0, 0, 0)
     
    #go through all the permutations using he running index,
    #which is i+j+k+l+m+n+o+p for weapons 8
    for z, _ in enumerate(all_perms[,1]):
        weapons = [weaponChoices[[all_perms[z,i]]]
                   for i in range(WEAPON_COUNT)]
        for i in range(WEAPON_COUNT):
            if weapons[4] == “”: continue
            index = 0
            for j in range(WEAPON_COUNT): index = index + all_perms[z,j]
            weapons[6] = lookup(f, index, 1)
            weapons[7] = lookup(f, index, 2)
       
       
        #time series - run time series at point t, save it to state,
        #update values spreading to state, re-run time series,
        #break if ship dies
        for time in range(total_time):
            state = time_series(time, shield, armor_hp, hull, shield_regen,
                                shield_max, armor_rating, armor_grid)
            shieldHP = state[[2]]
            armor_hp = state[[3]]
            hull = state[[4]]
            flux = shield_max - shield
            armor = state[[8]]
            if(hull == 0):
                flux = 0
                if time_to_kill == 0:
                  time_to_kill = time
                  break
               
        if time_to_kill == 0: time_to_kill = NA
       
        tobind = [time_to_kill, un[weapons[1][4]), un[weapons[2][4]),
                                un[weapons[3][4]), un[weapons[4][4]),
                                un[weapons[5][4]), un[weapons[6][4]),
                                un[weapons[7][4]), un[weapons[8][4]))
        timeSeries = rbind(timeSeries,tobind)
       
        hull = ship.hull
        armor_hp = ship.armor_rating
        armor_grid = ship.getarmor_grid
        shield = shield_max
        shot_counts = [0, 0, 0, 0, 0, 0, 0, 0)
        time_to_kill = 0
   
   
    colnames(timeSeries) =  ["time_to_kill", "Weapon1", "Weapon2",
        "Weapon3", "Weapon4", "Weapon5", "Weapon6", "Weapon7", "Weapon8")
     
    sortbytime = timeSeries[order(as.integer(timeSeries.time_to_kill)),]
     
    write.table(sortbytime, file = paste("optimizeweaponsbytime", ship.id,
                                         "allweaponswithspread.txt", sep = ""),
                row.names = FALSE, sep = "\t")



main():
    config = read.csv("config")
    weapon_data = read.csv("weapon_data.csv")
    ship_data = read.csv("ship_data.csv")
   
    ships = [glimmer, brawlerlp, vanguard, tempest, medusa, hammerhead,
             enforcer, dominator, fulgent, brilliant, radiant, onslaught,
             aurora, paragon, conquest, champion]

    #which weapons are we studying?
    mediumGuns = [arbalest, hac, hvd, heavymauler, needler, mortar)
    largeGuns = [gauss, markix, mjolnir, hellbore, hephaestus, stormneedler)
    mediumMissiles = [harpoon, sabot)
    largeMissiles = [squall, locust, hurricane)
   
    weapon_choices = [
        mediumGuns,
        mediumGuns,
        largeGuns,
        largeGuns,
        mediumMissiles,
        mediumMissiles,
        largeMissiles,
        largeMissiles
    )
   
    if PERMUTE_WEAPON_LOADOUTS: permute_weapon_loadouts(weapon_choices)
    else: all_perms = readRDS("lookuptable.RData")
   
    if GENERATE_LOOKUP_TABLE: generateLookupTable():
    else: table = readRDS("lookuptable.RData")
   
    #go through all ships
    for ship in ships: test_loadout(ships)

if __name__ == “__main__”: main()

[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 07, 2022, 10:14:42 AM
Well, it looks like that code is still using the old simulate distribution system. And in that system x could be defined differently. But here, it is just much simpler to define it like this. Using the analytical method, the choice of x is certainly not arbitrary as if it is left to right then CDF(0)=0.5 but if it is from center then CDF(0)=0.

I'd suggest just binning the old version and switching to the analytical completely now. Can run old version if need to compare. Performance (and accuracy) gain too great to ignore.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 07, 2022, 11:13:41 AM
Well, it looks like that code is still using the old simulate distribution system.

I figure!  Which code, exactly?

Quote
And in that system x could be defined differently. But here, it is just much simpler to define it like this. Using the analytical method, the choice of x is certainly not arbitrary as if it is left to right then CDF(0)=0.5 but if it is from center then CDF(0)=0.

Huh, funny how it works out!

Quote
I'd suggest just binning the old version and switching to the analytical completely now. Can run old version if need to compare. Performance (and accuracy) gain too great to ignore.

Sure, which methods and classes should I delete?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 07, 2022, 11:26:21 AM
Isn't that what the shotDistribution(spread) function does?

That function should be replaced by one that computes
P(cell_1) <- PrEltZ(upperbound(cell1))
P(cell_i) <- PrEltZ(upperbound(cell_i)-PrEltZ(upperbound(cell_(i-1))
P(cell_final) < 1-PrEltZ(upperbound(cell_(final-1))

To get the dist analytically.

Sorry, on mobile so I forgot what you called the CDF function so I used the old name
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 07, 2022, 12:45:57 PM
Isn't that what the shotDistribution(spread) function does?

That function should be replaced by one that computes
P(cell_1) <- PrEltZ(upperbound(cell1))
P(cell_i) <- PrEltZ(upperbound(cell_i)-PrEltZ(upperbound(cell_(i-1))
P(cell_final) < 1-PrEltZ(upperbound(cell_(final-1))

To get the dist analytically.

Sorry, on mobile so I forgot what you called the CDF function so I used the old name

Here is my implementation of what I understand you want in Python.


def upper_bound(cell):
    #TODO: Implement
    pass


def hit_distribution(spread, row):
    """
    Return distribution of hits across the front row of armor grid cells.
   
    Hits are normally distributed relative to the center of the row with
    spread
    """
    standard_deviation = #what?
    return ([hit_probability_within_width(upper_bound(row[0]), spread, standard_deviation)]
               + [hit_probability_within_width(upper_bound(row), spread, standard_deviation)
                    - hit_probability_within_width(upper_bound(row[i - 1]), spread, standard_deviation)
                   for i, _ in enumerate(row[1:-2])]
               + [1 - hit_probability_within_width(upper_bound(row[-1]]), spread, standard_deviation)])
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 07, 2022, 09:41:56 PM
All right, well I don't know Python but here is a simple implementation in R. I'm going to use my old definition of the CDF function using the definition G since I'm chronically short on time and looking at this in short bursts like 5 min just now but that doesn't matter so long as it's a correct definition of the CDF function.


G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
#dominator, so we can compare to previous graphs
shipwidth <- 220
shipcells <- 12

getUpperBoundVector <- function(shipwidth, shipcells){
  vector <- vector(mode="double",length = shipcells+2)
  vector[1] <- -shipwidth/2
  increment <- shipwidth/shipcells
  for (j in 2:(shipcells+1)) vector[j] <- vector[1]+(j-1)*increment
  vector[shipcells+2] <- shipwidth/2
  return(vector)
}

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds))
  vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
  for (j in 2:(length(upperbounds)-1)) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
  vector[length(upperbounds)] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)-1], standard_deviation, spread))
  return(vector)
}


This returns the probability to hit cells, with cells being MISS, shipcell_1, shipcell_2... shipcell_n, MISS

Edit: found a mistake in the code (calculation was correct but variable names in function #2 incorrect. fixed)

Let's add some plots to make sure it works

upperbounds<-getUpperBoundVector(shipwidth,shipcells)
hitdist <- hit_distribution(upperbounds,50,5/180*pi*1000)

plot(hitdist)


(https://i.ibb.co/JQkv8qP/image.png) (https://ibb.co/q51NhCw)

This is the same plot we saw earlier that was also produced by simulating the distribution with 100 000 iterations, so that's good.

Let's plot the distribution as accuracy goes from 0 to 20.


matrix <- matrix(0,294,3)
for (j in 0:20){
  for (i in 1:14){
    matrix[i+j*14,] <- (c(i,hit_distribution(getUpperBoundVector(shipwidth,shipcells),50,j/2/360*2*pi*1000)[],j))
  }
}
library(ggplot2)

plot(matrix[,2]~matrix[,1],type="b",col=floor(matrix[,3]/3))



(https://i.ibb.co/KqhcjWy/image.png) (https://ibb.co/G2pfvRP)

Seems good!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 08, 2022, 03:37:28 AM
Double posting just to make sure you notice this Liral, but I noticed upon playing with ggplot (how did I miss this originally! dividing by zero is not acceptable) that if you use the Willink / Bhattacharjee function F(c) = (G((c + b) / a) - G((c - b) / a)) * a / 2b - which you should, or whatever tailored form of it - then you must special case for when b = 0 and a=0 which is possible - in fact b=0 occurs routinely in Starsector.

Specifically, the function should be
F(c) = (G((c + b) / a) - G((c - b) / a)) * a / 2b, a, b !=0
F(c) = Phi_a(c), b=0, a != 0, where Phi_a is the CDF of a normal distribution N(0,a^2)
F(c) = min(1,max(0,(x+b))/2b), a=0, b != 0
F(c) = 1, c>=0 and F(c) = 0, c<0, a=0 and b=0.

To incorporate this into the code, we should write

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds))
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds)-1)) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
  if (spread != 0){
    vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
    for (j in 2:(length(upperbounds)-1)) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
    vector[length(upperbounds)] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)-1], standard_deviation, spread))
  } else {
    #if spread is 0 but standard deviation is not 0 we have a normal distribution
      for (j in 1:(length(upperbounds)-1)) vector[j] <- pnorm(upperbounds[j], mean=0, sd=standard_deviation)
      for (j in 2:(length(upperbounds)-1)) vector[j] <- vector[j] - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)] <- 1-pnorm(upperbounds[length(upperbounds)-1], mean=0, sd=standard_deviation)
    }

  }
  return(vector)
}


Here are some graphs:

upperbounds<-getUpperBoundVector(shipwidth,shipcells)
hitdist <- hit_distribution(upperbounds,0,0)
plot(hitdist)

(https://i.ibb.co/pLjLFpw/image.png) (https://ibb.co/DRfR2nW)
Trivial distribution, all shots hit 1 cell



upperbounds<-getUpperBoundVector(shipwidth,shipcells)
hitdist <- hit_distribution(upperbounds,50,0)
plot(hitdist)

Normal distribution
(https://i.ibb.co/FVkgycj/image.png) (https://ibb.co/ZmRWFvk)


upperbounds<-getUpperBoundVector(shipwidth,shipcells)
hitdist <- hit_distribution(upperbounds,0,5/180*pi*1000)
plot(hitdist)

Uniform distribution with edge cases
(https://i.ibb.co/pbvzrRk/image.png) (https://ibb.co/7jJWNR0)


upperbounds<-getUpperBoundVector(shipwidth,shipcells)
hitdist <- hit_distribution(upperbounds,50,5/180*pi*1000)
plot(hitdist)

Smeared normal (Bhattacharjee) distribution
(https://i.ibb.co/JQkv8qP/image.png) (https://ibb.co/q51NhCw)

Some edits: a few issues while coding, current version produces graphs
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 08, 2022, 03:29:12 PM
Woah, that's a lot of graphs!  Here's my best translation of what you've written.

def right_bounds(width, cells):
    """
    Return the coordinates of the further edge of each of an
    armor grid row given width and number of cells.

    width - distance from the beginning of the row to the end
    cells - how many cells are in the row
    """
    size = width / cells
    half = width / 2
    return [-half] + [-half + (i - 1) * size for i in range(1, cells)] + [half]


def hit_probability_distribution(bounds, standard_deviation, spread):
    """
    Return the chance for a projectile to hit each armor
    cell of a row split by bounds.
   
    We assume aim angles to be uniformly distributed
    between 0 and spread and all cell bounds to be offset
    by the same amount, normally distributed around 0 with
    standard_deviation.
   
    This function is analytical rather than computational.
    It also encounters and handles the special cases of
    zero standard deviation or spread.
   
    bounds - list of right-sided bounds separating the
             armor cells
    standard_deviation - standard deviation of the
                         normal distribution from which
                         a sample is added to hit location
                         should this argument exceed 0
    spread - the maximum angle that the weapon might
             uniformly-randomly aim to either side of the
             ship center
    """
    #Special Cases
    if standard_deviation == 0 and spread == 0: #all shots hit one cell
        return
  • + [1 if bound >= 0 and bound[b - 1] < 0 else 0

                      for b, bound in enumerate(bounds[1:])]
    elif standard_deviation == 0: #return part of a box
        def f(bound, spread): #helper function
            return min(1, max(0, (bound + spread)) / (2 * spread))
        return ([f(bounds[0], spread)]
                + [f(bound, spread) - f(bounds[b - 1], spread) for b, bound in
                   enumerate(bounds[1:])]
                + [1 - f(bounds[-1], spread)])
    elif spread == 0: #normal distribution
        vector = [None for _ in bounds]
        normal_dist = NormalDist(0, standard_deviation)
        for b, bound in enumerate(bounds[:-2]):
            vector = normal_dist.cdf(bounds)
        for b, bound in enumerate(bounds[1:-2]):
            vector = vector - normal_dist.cdf(bounds[b - 1])
        vector[-1] = 1 - normal_dist.cdf(bounds[-2])
        return vector
    #Usual Case
    probability = probability_hit_coordinate_less_than_x
    return ([probability(bounds[1], standard_deviation, spread)]
            + [probability(bound, standard_deviation, spread)
                - probability(bounds[b-1], standard_deviation, spread)
                for b, bound in enumerate(bounds[1:-2])]
            + [1 - probability(bounds[-2], standard_deviation, spread)])
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 08, 2022, 08:52:55 PM
Seems good, I don't understand the logical structure of Python but I assume that is correct. Now it's just a matter of using that code to get the distribution and then the program should quickly compare an arbitrary set of weapons with an arbitrary parameter for positional error. Positional error may be something modders might want to test different values of, as it might be interesting to see if some weapons are specifically relatively better in a hectic fleet situation (large SD, I imagine) than when firing at a static target (0 SD). A default should still be suggested, probably the 0.05*range for now unless we find a better default.

We can use the code that exists already to demonstrate a mildly interesting fact about space combat: less accurate weapons are less affected by positional error (ie. enemy movement) than more accurate weapons, at least when it comes to hit rate.

upperbounds <- getUpperBoundVector(220,12)
misspercentbysdbyacc <- data.frame()

for (i in 0:10) {
  for (j in 0:20)
    misspercentbysdbyacc <- rbind(misspercentbysdbyacc,c(2*hit_distribution(upperbounds,i*10,j/2/180*pi*1000)[1],i*10,j))
}
library(ggplot2)
colnames(misspercentbysdbyacc) <- c("missrate","positionalerror","weaponmaxspread")

ggplot(data=misspercentbysdbyacc,aes(x=positionalerror,y=missrate,group=weaponmaxspread, color=weaponmaxspread))+
  geom_point(shape=18)+
  geom_line(orientation="y")

(https://i.ibb.co/SP9brRP/image.png) (https://ibb.co/yXbtSWX)
(Vs Dominator, range 1000)

I do find the non-linear transition at lower left quite fascinating. This seems to mean that moderately accurate weapons are relatively the most affected by low levels of enemy movement. The intuitive explanation would go something like this: if the weapon is already very random, then adding an independent extra source of randomness doesn't matter that much. If the weapon is hitting dead center, then the enemy moving a little doesn't make the shot miss because the enemy is not likely to be able to move out of the way entirely in time. But if the weapon has something of a chance to miss, then the enemy moving a little out of the way will make that chance greater.
(Mathematically: the miss rate of a spread 0 weapon does not appreciably increase until the CDF of the lower bound of the enemy ship has an appreciable value. Moderately accurate weapons gain fatter tails sooner from the convolution of the normal distribution by the uniform distribution since the uniform distribution is wider. If the accuracy is already close to a uniform distribution near the ship convolution doesn't change it as much in absolute terms near the ship).

Note that the order of accuracy does not change. More accurate weapons are still more accurate when the enemy moves. But if we assume weapons are so balanced that they have the same dps at a particular range at a particular enemy movement speed, then this does mean that changing those parameters would shift the DPS balance in a way that can only be computed considering the parameters we have here.

Next step: make the enemy ship exhibit Brownian motion with a maximum velocity instead, and our ship follow it with a maximum velocity and rotate turrets at a maximum angle velocity. Then what is the probability to hit as a function of time. Just kidding. We'll do that later when this model is done.

E: FWIW just looking at this graph, the discriminatory power ie. differentiation between weapons is highest at lower but non-zero SDs. Of course in terms of the real game we might rather set SD=ship top speed. Or might consider this: give each weapon and ship its own SD, given by projectile travel time * enemy top speed / 2. Then we're essentially saying that 95% of the time, the enemy's position relative to the original location has changed at most by its maximum travel under its own propulsion during that time (why it might "change more" is differences in our own position and facing compared to the ideal). This would have been enormously impractical with the simulated distributions but would be easily done now. Of course, still comes at a high computing cost meaning may not be worth. Also, for the Dominator we actually found the "real" SD was 50, compared to 30 top speed, so errors from the ideal are non-negligible and the "real real" would be something like projectiletraveltime*enemytopspeed/a+b where a and b are adjustable constants.

E2: here are some more plots using 200 901 samples.
Vs Dominator (width 220 px, 12 cells, range 1000).  The positional error is accidentally displayed as 5x on the x axis.
(https://i.ibb.co/dMckSpc/rainbow-dominator.png) (https://ibb.co/4tf4cSf)
(https://i.ibb.co/NKPTm4V/viridis-dominator.png) (https://ibb.co/TtSWbCv)

Vs Glimmer (width 78 px, 5 cells, range 1000)
(https://i.ibb.co/Br6sb8D/glimmer-rainbow.png) (https://ibb.co/tx2Cgyf)
(https://i.ibb.co/CvynJ68/glimmer-viridis.png) (https://ibb.co/2Prj8Sh)

I'm also taking back what I said about SD=shipwidth/2.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 09, 2022, 06:52:24 AM
Seems good, I don't understand the logical structure of Python but I assume that is correct.

R and Python syntax differ largely by a few substitutions:

Case
camelCaseForR

snake_case_for_python

Types
R: "character", "numeric", "list", ...
Python: float, int, boolean, string, list, tuple, ...

Curly Braces and Code Blocks
{
    R
    breaks
    multi
}{
    line
    blocks
    with
}{
    curly
    braces
    aren't
}{
    they
    just
    everywhere?
}

{"Python" => "curly", "braces" => "declare dictionaries"}
Python breaks code blocks
    with just indentation,
        but you must use the same multiple of spaces
            for each level of indentation,
                or else get a syntax error

Assignment
variableInR <- value
python_variable = value

Function Declaration
functionInR <- function(arguments) { 
    body
}

def python_function(arguments):
    body

Class Declaration
classInR <- setRefClass(
    "className",
    fields = list(
        fieldName = "numeric",
        otherFieldName = "character",
        yetAnotherFieldName = "list",
    ),
    methods = list(
        ...
    )
)

class PythonClassesAreCamelCaseHowever:
    class_variable = value
    other_class_variable = other_value
    def __init__(self, argument, keyword_argument = default_value):
         self.instance_variable = argument
         self.other_instance_variable = keyword_argument
   
    def instance_method(self, argument, keyword_argument = default value):
         ...

Documentation
#R
#makes
#you
#type
#long
#comments
#like
#this

"""
Python
Allows
Multi
Line
Comments
Which
Generate
Doc
Strings
When
Under
Class
And
Method
Declarations
"""

Indexing
1, 2, 3, 4
R_last_item <- iterable[length(iterable) + 1]

0, 1, 2, 3
python_last_item = iterable[-1]

List Declaration
listInR <- list(element1, element2, element3)

python_list = [element0, element1, element2]

Conditionals
if (R) {
    ...
} else if (S) {
    ...
} else {
    ...
}

if python:
    ...
elif viper:
    ...
else:
    ...


Loops
R
for (i in a:b) {
    ...
}

while (condition) {
    ...
}

Python

for i in range(a, b):
    ...

for item in iterable:
    ...

for index, item in enumerate(iterable):
    ...

while condition:
    ...


Quote
Now it's just a matter of using that code to get the distribution and then the program should quickly compare an arbitrary set of weapons with an arbitrary parameter for positional error. Positional error may be something modders might want to test different values of, as it might be interesting to see if some weapons are specifically relatively better in a hectic fleet situation (large SD, I imagine) than when firing at a static target (0 SD). A default should still be suggested, probably the 0.05*range for now unless we find a better default.

Neat! :D
Quote
We can use the code that exists already to demonstrate a mildly interesting fact about space combat: less accurate weapons are less affected by positional error (ie. enemy movement) than more accurate weapons, at least when it comes to hit rate.

Oooooh, more features to add in the future! :D  Speaking of code that we already have, I am getting lost amid all the old R stuff, new R stuff, and now Python stuff.  I wish we had a list of everything we wanted somewhere—specifying is much easier than coding!  Here's an example description of a tool to test the balance of the autofit variants of a faction or ship-and-weapon pack.

I. Setup
1. Be a standalone Python desktop app in the mods folder and launch with one click.
2. Check a config file for the:
    a. mod the user wants to test
    b. mods to exclude

II. Data
1. From the vanilla folder and mods folders
    a. Get the relevant data of all the ships
    b. Get the autofit variants of these ships
2. Turn that data into the numbers the testing calculations need.

III. Performance Analysis
For every subject ship
    for every variant of its autofit variants
        for every test ship
            for every variant of its autofit variants
                find the time-to-kill of the test variant by the subject variant and vice versa
                save these times-to-kill into a file grouped by subject ship

IV. Balance Evaluation
Save the win-loss ratio of every subject ship by comparing its time to kill pairs.
------
This could get complicated quickly...
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 09, 2022, 08:11:36 AM
Well, I don't know about the database etc. stuff, I'm more of a math guy. But I can imagine three functions for modders.

1. DiagnoseWeapon - this should print a graphical presentation of the weapon's shot spread (ie. shot PDF) at its maximum range, and a plot that gives DPS as a function of ship size and SD(ie. a heatmap), as well as average time to kill the target ships.
2. Testship - gives an average time to kill the ship using available weapons
3. Weaponbalance - prints a list of weapons and weapon combinations like in the OP of this thread from selected mods

Also 4. Average ttk for ships from selected mods to test their balance

Your idea to test specific loadouts is also good and something I didn't think of.

Almost all of these are already implemented in R, for example the code already goes through pre-set loadouts by name of permutations ("allperms"). So just a matter of putting all the pieces together. For testing ships must decided on ensemble of weapons to use (eg. is it 2 large missiles,. 2 large guns etc. or something else)

If feeling fancy could implement flux to kill since this is another balance consideration.

Heck, though, you are a modder and I am not, so you probably know this better also.

Also must implement soft flux. And the algorithm to generate time series from weapon refire and firing delay. The latter is needed because the time series operates in increments of 1 second. You could also ofc shorten it to 0.1 sec and avoid this process, but that would mean 10 times the computations.

The latter shouldn't be too hard. Mathematically we do this
1. Case firing takes 0 seconds and the weapon takes x seconds to reload:
X is a rational number p/q (unless a modder has decided to, say, use sqrt(2) as the reloading delay in which case we are in trouble). Compute q/p which is shots/second. Make a vector of length p, then add q shots to it spread evenly. Eg lets say a weapon fires 1 shot in 3/7 seconds. Then the weapon fires 7 shots in 3 seconds. We want the function to return the vector (2,2,3). This is computed as set all cells to floor (7/3) and compute 7-sum(vector), then if this is >0 add 1s to cells one by one starting from the last until sum(vector)=7.
2. Case firing takes x seconds and reloading takes r/s seconds. We compute that the weapon takes on average r/s+xs/s = (r+xs)/s seconds to fire. Call this p/q and proceed as above.

Right?

Nvm there are also bursty weapons. In this case we do
1. Compute length of burst from firing delay, and burst size, giving us (burst length)/(burst size). Call this p/q and proceed as above. Store this vector.
2. Compute a vector of length reload delay. Concatenate this to above.
3. If the reloading delay was not a whole number, then it is a rational number r/s. Do above with floor(r/s) seconds per reload s times, then concatenate 0 to the last vector (r/s-floor(r/s))*s times. It is better if you figure out a way to spread the zeros more evenly, probably keep track of the remainder and add zeros as you go instead during the loop.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 09, 2022, 09:23:09 AM
Well, I don't know about the database etc. stuff, I'm more of a math guy. But I can imagine three functions for modders.

I think there will obviously have to be library tools for researchers like you.  :D

Quote
1. DiagnoseWeapon - this should print a graphical presentation of the weapon's shot spread (ie. shot PDF) at its maximum range, and a plot that gives DPS as a function of ship size and SD(ie. a heatmap), as well as average time to kill the target ships.
2. Testship - gives an average time to kill the ship using available weapons
3. Weaponbalance - prints a list of weapons and weapon combinations like in the OP of this thread from selected mods

Also 4. Average ttk for ships from selected mods to test their balance

Sounds good to me.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 10, 2022, 02:48:27 AM
Alrighty here's some code for the time series, sorry no time to really comment it just now
Code
#compute greatest common divisor of integers p and q using Euclid's algorithm
gcd <- function(p,q){
  if(q>p) return(gcd(q,p))
  if(q != 0) return(gcd(q,(p %% q)))
  return(p)
}

#my function was imprecise so we're going to import one from library "MASS"

library(MASS)
fraction <- function(x) {
  frac <- as.character(fractions(x))
  frac1 <- as.integer(gsub("([^\\/]+)\\/", "", frac))
  frac2 <- as.integer(gsub("\\/([^\\/]+)", "", frac))
  return(c(frac2,frac1))
}

#return a reduced fraction
reduce <- function(frac){
  gcd <- gcd(frac[1],frac[2])
  return(c(frac[1]/gcd,frac[2]/gcd))
}
reducedrational <- function(x) return(reduce(fraction(x)))

#now convert the numbers available in the starsector weapons file to a time series
#maxshots=0, the default, means no burst action, only continuous fire

shottimeseries <- function(chargeup,chargedown,burstsize,burstdelay,maxshots=0){
  #we do not really care to distinguish charge up and charge down most of the time
  firingdelay <- chargeup+chargedown
  #1. no burst delay ie. continuous fire
  if (burstdelay == 0){
    #pqvector stores shots / second as a fraction
    pqvector <- reducedrational(burstsize/firingdelay)
   
    #in the output vector we express the fraction in terms of spreading the number of shots over a whole number of seconds
    # to do this, the length of the vector must be equal to the number of seconds, if firing less than 1 shot / second
    #1.1 case firing delay is at least 1 second
    if(firingdelay >= 1){
      #then additionally in this case to get the correct result pqvector must be scaled so that the number of shots
      #fired at once is always the correct burst size, when firing multiple shots at once
      #for example the reduced rational number of firing 6 shots every 10 seconds is 3/5, but we do not
      #want to return a time series that fires 3 shots every 5 seconds, but 6 shots every 10 seconds
      #because pqvector[1] is a the reduced numerator of burstsize/firingdelay this should be a whole number
      pqvector <- pqvector*burstsize
      maxlength <- pqvector[2]

      #additionally, if we have a maximum shot count, then the length of the vector should instead
      #be such that it will produce it
      if(maxshots!=0) maxlength <- maxshots/burstsize*maxlength
      if(maxshots==0) maxshots <- pqvector[1]
      outputvector <- vector(mode = "double", length = maxlength)
      increment <- firingdelay

      #starting position to add is 1, because we deal with charge up time later
      index <- 1
      while(sum(outputvector) < maxshots) {
        #we'd like to spread these evenly per firing duration, so the index can actually be a real number
        #and we'll round it
        #additionally, the weapon fires burstsize shots at once
        if(index > length(outputvector)) index <- index-length(outputvector)
       
        outputvector[round(index,0)] <- outputvector[round(index,0)] + burstsize
       
        index <- index+increment
        }
      } else {
        #we're firing more than one shot per second, so we will express this as a fraction
        #the length of the vector should be the denominator of the reduced fraction
        #and the numerator should be the number of shots fired per second, to begin with
        maxlength <- pqvector[2]
        #additionally, if we have a maximum shot count, then the length of the vector should instead
        #be such that it will produce it
        #furthermore, the time to fire a burst might be itself fractional, so first we deal with
        #the whole number part
        shotspersecond <- fraction(firingdelay)
        if(maxshots==0) maxshots <- pqvector[1]
        if(maxshots!=0) maxlength <- max(1,floor(maxshots*firingdelay))
        outputvector <- vector(mode = "double", length = maxlength)
        for (i in 1:length(outputvector)) outputvector[i] <- 0
        increment <- 1
       
        #starting position to add is 1
        index <- 1
        #we need to stop adding numbers to the vector when we either reach the maximum shots for a burst
        #or the cap for how many shots can be fired in the time that the vector represents
        while(sum(outputvector) < min(maxshots,maxlength/firingdelay)) {
          #we'd like to spread these evenly per firing duration, so the index can actually be a real number
          #and we'll round it
          if(index > length(outputvector)) index <- index-length(outputvector)
          outputvector[round(index,0)] <- outputvector[round(index,0)] + 1

         
          index <- index+increment
        } 
       
        #now we deal with however many shots were left over, which is

        leftovers <- maxshots-sum(outputvector)
        shotsleftover <- leftovers
        #this might still be a fraction, but we don't really want to write more recursive functions
        #to create a discrete representation. So let's say the number is 2.4. We'll just add
        #a 2 60% of the time and a 3 40% of the time and call it a day.
        #note that this will only happen if we are unable to find a representation p/q+k where p,q and k are integers
        #for the shot number, so this should not happen with any normal weapons except if it's due to spaghetti code.
        shotsleftover <- floor(shotsleftover)
        if(runif(1,0,1) < shotsleftover-floor(shotsleftover)) shotsleftover <- shotsleftover + 1
        if(shotsleftover > 0) outputvector<-c(outputvector,shotsleftover)
      }
    return(outputvector)
  }
  #2. there is a burst delay ie. firing in bursts
  #2.1. the burst part
  if (burstdelay > 0){
    burstvector <- shottimeseries(0,burstdelay,1,0,burstsize)
    #2.2 the reload part
    #we must reduce any fractional time left over from the burstdelay from this
    burstresidue <- burstsize*burstdelay-floor(burstsize*burstdelay)
    firingdelay <- firingdelay - burstresidue
    reloadfractionvector <- reducedrational(firingdelay)
    remainder <- reloadfractionvector[1]/reloadfractionvector[2]-floor(reloadfractionvector[1]/reloadfractionvector[2])
    #to avoid making absurdly long vectors we'll round the remainder to 2 places here
    remainder <- round(remainder,2)
    if(remainder == 0) {
      reloadvector <- vector(mode = "double", length = firingdelay)
      return(c(burstvector,reloadvector))
    } else {
      reloadvector <- vector(mode = "double", length = firingdelay)
      remainderfraction <- reducedrational(remainder)
      remaindertrackernumerator <- 0
      remaindertrackerdenominator <- 0
      currentvector <- vector(mode="double")
      while(remaindertrackerdenominator < remainderfraction[2]){
        currentvector <- c(currentvector, burstvector, reloadvector)
        if(remaindertrackernumerator < remainderfraction[1]){
          #we'd like to split this more evenly, so given that we are adding remaindertrackernumerator
          #times a zero while adding remaindertrackerdenominator reloads,
          #we can say we should add a zero every (numerator/denominator) distributions
          #that is, let numerator/denominator be p/q, and we are adding p times over q distributions
          #then this should happen p times as remaindertrackerdenominator goes to q, so
          #every q/pth distribution, given we are adding q distributions
          if(remaindertrackerdenominator %% floor(remainderfraction[2]/remainderfraction[1]) == 0){
        currentvector <- c(currentvector, 0)
        remaindertrackernumerator <- remaindertrackernumerator + 1
          }
        }
        remaindertrackerdenominator <- remaindertrackerdenominator + 1
      }
      return(currentvector)
    }
    }
}

Result

> #locust
> shottimeseries(0,5,40,0.1)
[1] 40  0  0  0  0  0
> #weird locust
> shottimeseries(0,5.7,40,0.1)
[1]  7 10
 [1] 40  0  0  0  0  0  0 40  0  0  0  0  0  0 40  0  0  0  0  0  0 40  0  0  0  0  0  0 40  0  0  0  0  0  0
[36] 40  0  0  0  0  0  0 40  0  0  0  0  0  0 40  0  0  0  0  0 40  0  0  0  0  0 40  0  0  0  0  0


Wait, that's not right... The burst shouldn't be instantaneous. Fix later.

E:Fixed and updated code.

Result now:


> #locust
> shottimeseries(0,5,40,0.1)
[1] 10  1
[1] 4
[1] 10 10 10 10  0  0  0  0  0
> #weird locust
> shottimeseries(0,5.44,56,0.1)
[1] 10  1
[1] 5.6
  [1] 11 11 11 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0  0 11
 [35] 11 11 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0  0 11 11
 [69] 11 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0  0 11 11 11
[103] 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12
[137]  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12  0  0  0  0
[171]  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11
[205] 11 12  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12  0  0
[239]  0  0  0 11 11 11 11 12  0  0  0  0  0 11 11 11 11 12  0  0  0  0  0


E: evened distribution. Result after latest update

> shottimeseries(0,5.44,56,0.1)
  [1] 12 11 11 11 11  0  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0  0 12 11
 [35] 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11
 [69]  0  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0  0 12 11 11 11 11  0  0
[103]  0  0  0 12 11 11 11 11  0  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0
[137]  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11
[171] 11 11 11  0  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0  0 12 11 11 11
[205] 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0
[239]  0  0  0 12 11 11 11 11  0  0  0  0  0 12 11 11 11 11  0  0  0  0  0


E4: I figured out that wasn't respecting burst delay, so fixed code again and here is the latest result:

> shottimeseries(0,5.44,56,0.1)
  [1] 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10
 [35] 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10
 [69] 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10
[103] 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10
[137] 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10
[171]  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3
[205]  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0  0  0  0  0 10 10 10 10 10  3  0
[239]  0  0  0 10 10 10 10 10  3  0  0  0  0 10 10 10 10 10  3  0  0  0  0 10 10 10 10 10  3  0  0  0  0



E5(current)

> shottimeseries(0,5.44,56,0.1)
  [1] 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10
 [35] 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10
 [69] 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10
[103] 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10
[137] 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10
[171]  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6
[205]  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0
[239]  0  0  0 10 10 10 10 10  6  0  0  0  0 10 10 10 10 10  6  0  0  0  0 10 10 10 10 10  6  0  0  0  0
>
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 10, 2022, 10:12:26 AM
Alrighty here's some code for the time series, sorry no time to really comment it just now

Thanks, I needed this!  Time to reformat it into Python and integrate it. 
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 10, 2022, 10:17:32 AM
Yeah I think what you want to do is only add a zero every denominator/numerator iterations in the last part instead, but I ran out of time with this one, sorry. May contain errors but the basic idea should be there.

That is, you should add zeroes that way to make them spread evenly, I mean. It should add a correct no. zeroes as is but they are not spread evenly over the cycle.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 10, 2022, 02:30:33 PM
Yeah I think what you want to do is only add a zero every denominator/numerator iterations in the last part instead, but I ran out of time with this one, sorry. May contain errors but the basic idea should be there.

That is, you should add zeroes that way to make them spread evenly, I mean. It should add a correct no. zeroes as is but they are not spread evenly over the cycle.

I will wait for you to have more time before messing with this code.  ;D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 10, 2022, 04:21:39 PM
While translating the code from R to Python, I have noticed that the latest method determines the weapon to fire in bursts should the burst delay exceed zero, but I feel a clearer check would be whether the burst size is greater than one because the game requires the burst size of a continuously-firing weapon to be 1 rather than 0.  Next, would you please show me what you changed to fix the problem?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 11, 2022, 12:18:44 AM
While investigating this issue I found several instances of problems in the code, including not checking that a vector has a minimum length of 1, an issue with vector indexing again, and also I found out that there were great problems with fraction rounding as the computer might not compute fractions the way a human might (eg. instead of calculating 1/1.2 = 5/6 it would calculate it as 833/1000 using the way I did things) leading to weirdness. I had to re-write this code using actual tools to deal with fractions from library MASS. I edited the fixed code into the earlier post. There was a specific problem that we do not always want to reduce the fraction fully which I explained in the comments. Additionally, the burst code was not respecting burst delay, so I fixed that. Also changed "nominator" to "numerator" in the last section of the code.

I also added consideration for the charge-up time into the code, specifically so that indexing is started after charge-up time, so if a weapon has a charge-up time of 3 and charge-down time of 3, we get
[1] 0 0 1 0 0 0
rather than
0 0 0 0 0 1

I'm not sure which problem you refer to, but here's about the burst issue. I think the game file terminology is actually a little misleading in this case, as if a weapon fires 3 shots at once I would call it a shotgun type weapon rather than burst fire. Although I suppose it is a burst of fire. Anyway, the appropriate check is burst delay, and here is the reason.

If a weapon fires 5 shots at once, then reloads for 5 seconds, we want the code to return
5 0 0 0 0
Here is the output from current code
> shottimeseries(0,5,5,0)
[1] 5 0 0 0 0

If a weapon fires 5 shots in a burst where 1 shot takes 0.2 seconds, we want the code to return
5 0 0 0 0 0
Here is the output from current code
> shottimeseries(0,5,5,0.2)
[1] 5 0 0 0 0 0

If a weapon fires 5 shots in a burst where 1 shot takes 1 second, we want the code to return
1 1 1 1 1 0 0 0 0 0
Here is the output from current code
> shottimeseries(0,5,5,1)
 [1] 1 1 1 1 1 0 0 0 0 0

So that's how burst works.

The code ended up being pretty non-trivial and includes stuff like shortening the reload period by the fraction of a second that is left over after burst firing if the burst firing does not take up the whole second. It might still be rickety so let me know if you encounter anything that doesn't sound right.

E:just for fun, let's look at some time series!


twohundredplaces <- function(vector){
  while (length(vector) < 200) vector <- c(vector, vector)
  return(vector[1:200])
}


df <- matrix(0,200,200)

index <- 1
for(i in 1:4) for(j in 1:5) for(k in 1:10) {
  vector <- twohundredplaces(shottimeseries(j,j,5*i,1/(10-k+1)))
  df[index,] <- vector
  index <- index+1
}
df
library(ggplot2)
library(reshape2)
melted_matrix <- melt(df)
colnames(melted_matrix) <- c("cycle_burstsize_reloadtime_burstdelay","timepoint","shots")
ggplot(melted_matrix, aes(timepoint, cycle_burstsize_reloadtime_burstdelay, fill= shots)) +
  geom_tile()



Legend: x axis: timepoint
y axis: cycle (large cycle: burst size, being 5,10,15,20, medium cycle: chargeup and chargedown, from 1 to 5 seconds, small cycle: burst delay, going from 0.1 to 1 seconds in increments of 0.1, color: number of shots fired at timepoint)

(https://i.ibb.co/dbZSjmk/timeseries.png) (https://ibb.co/Q80ZpDY)

Edit: thanks to the strange bright spots on the upper lines of the previous plot I found one more bug and fixed it in the code. Here is the new version of the plot and the code is again fixed. This looks good - the shots are diminishing smoothly as the parameters go up.

You can also see from this plot that you can use any of the parameters to generate more shots per second on average, with different results for the temporal distribution of shots.

Edit: made some more change to the code, now it has a back-up random thingy and handles fractional shots better to deal with even really weird guns like
shottimeseries(pi,pi,314,1/pi)
(I'm not going to post the result as it is over 10 000 numbers for the firing sequence)
In the process I broke and fixed it again, so here's a new plot in theme viridis to make sure it works like before.

(https://i.ibb.co/Qnbt722/timeseries.png) (https://ibb.co/2PnH0DD)

While the lines near the bottom look thinner they are actually being handled correctly. E.g.
> shottimeseries(1,1,5,0.4)
[1] 3 2 0 0

The current code does not pay attention to charge up period for burst weapons, the charge up and charge down periods are merged into one reloading period from which remainder seconds can be subtracted. This could be added later if desired. The appropriate method would be rotating the entire finished vector to move zeros from back to front.

1 more edit: fixed a bug that was causing a delay of 1 sec to firing some guns and added a shifter function to do just this. But then I realized that when the chargeup period gets very long then this will actually rotate shots to the front.

So for now I think it's best to just skip the charge up thing until we know what to do with it. The problem is can't add zeroes to the front since the script works by looping over the time series vector for the weapon...  Edited code to match.

Ok I'm going to leave it alone for now so you can check it out Liral and see what you think. For anybody reading I'm going to summarize what we're doing here: trying to represent as whole numbers the distribution over time of shots fired by arbitrary Starsector guns, second by second. This is done by averaging, eg. if we have a gun that fires 2 shots and then does not fire for 3.5 seconds, we want to write 2 0 0 2 0 0 0. Needs to be done, because we want to do the whole time series calculation over a lattice of whole seconds and whole shots to actually be able to calculate the millions of models that the weapon comparison tool will require.

Then some day, we'll know what the optimal Conquest is. And then we can actually play the game.

By the way, it occurs to me that since we are doing Lattice Starsector Dynamics (with apologies to any physicists present), the enemy ship is in Brownian motion with our ship in pursuit thing might be tractable after all. Could just calculate a time series and run many of those, averaging...
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 11, 2022, 10:52:03 AM
While investigating this issue I found several instances of problems in the code, including not checking that a vector has a minimum length of 1, an issue with vector indexing again, and also I found out that there were great problems with fraction rounding as the computer might not compute fractions the way a human might (eg. instead of calculating 1/1.2 = 5/6 it would calculate it as 833/1000 using the way I did things) leading to weirdness. I had to re-write this code using actual tools to deal with fractions from library MASS. I edited the fixed code into the earlier post. There was a specific problem that we do not always want to reduce the fraction fully which I explained in the comments. Additionally, the burst code was not respecting burst delay, so I fixed that. Also changed "nominator" to "numerator" in the last section of the code.

I also added consideration for the charge-up time into the code, specifically so that indexing is started after charge-up time, so if a weapon has a charge-up time of 3 and charge-down time of 3, we get
[1] 0 0 1 0 0 0
rather than
0 0 0 0 0 1

Glad you got it fixed! :D  This is no small job and must have taken quite some work.

Quote
I'm not sure which problem you refer to, but here's about the burst issue. I think the game file terminology is actually a little misleading in this case, as if a weapon fires 3 shots at once I would call it a shotgun type weapon rather than burst fire. Although I suppose it is a burst of fire. Anyway, the appropriate check is burst delay, and here is the reason.

If a weapon fires 5 shots at once, then reloads for 5 seconds, we want the code to return
5 0 0 0 0
Here is the output from current code
> shottimeseries(0,5,5,0)
[1] 5 0 0 0 0

If a weapon fires 5 shots in a burst where 1 shot takes 0.2 seconds, we want the code to return
5 0 0 0 0 0
Here is the output from current code
> shottimeseries(0,5,5,0.2)
[1] 5 0 0 0 0 0

If a weapon fires 5 shots in a burst where 1 shot takes 1 second, we want the code to return
1 1 1 1 1 0 0 0 0 0
Here is the output from current code
> shottimeseries(0,5,5,1)
 [1] 1 1 1 1 1 0 0 0 0 0

So that's how burst works.

The number and timing of the projectiles a weapon fires depends also on the weapon file: how many firing offsets there are and whether they are specified to be LINKED or ALTERNATING.  The code therefore must handle several weapon categories:
It must also handle an edge case I have seen: an enormous but interruptible burst meant to work like automatic fire.

Therefore, the function shottimeseries must have the arguments
chargeup, chargedown, burstsize, burstdelay, barrels, linked, maxshots=0


Quote
The code ended up being pretty non-trivial and includes stuff like shortening the reload period by the fraction of a second that is left over after burst firing if the burst firing does not take up the whole second. It might still be rickety so let me know if you encounter anything that doesn't sound right.

The code has three while loops, two of which are almost identical and therefore could be moved past the control flow and merged into one, and I wonder if the third one could nevertheless be merged-in.

Quote
The current code does not pay attention to charge up period for burst weapons, the charge up and charge down periods are merged into one reloading period from which remainder seconds can be subtracted. This could be added later if desired. The appropriate method would be rotating the entire finished vector to move zeros from back to front.

Making changes to the code later would be tougher because I would have translated it into Python.  I hope you will have a little while to pick up the syntax because it writes just like R does, and it would save me time while letting you edit the code.  Also, the Python standard library has the fraction feature you implemented and supported yourself, complete with reduction. :P

Code
from fractions import Fraction

thirty_seven_percent_ugly = Fraction(0.37)
thirty_seven_percent_pretty = Fraction(0.37).limit_denominator()
print(thirty_seven_percent_ugly)
print(thirty_seven_percent_pretty)

Terminal
3332663724254167/9007199254740992
37/100


Quote
Ok I'm going to leave it alone for now so you can check it out Liral and see what you think. For anybody reading I'm going to summarize what we're doing here: trying to represent as whole numbers the distribution over time of shots fired by arbitrary Starsector guns, second by second. This is done by averaging, eg. if we have a gun that fires 2 shots and then does not fire for 3.5 seconds, we want to write 2 0 0 2 0 0 0. Needs to be done, because we want to do the whole time series calculation over a lattice of whole seconds and whole shots to actually be able to calculate the millions of models that the weapon comparison tool will require.

Then some day, we'll know what the optimal Conquest is. And then we can actually play the game.

But before then, we might want to parallelize!  ;D

Quote
By the way, it occurs to me that since we are doing Lattice Starsector Dynamics (with apologies to any physicists present), the enemy ship is in Brownian motion with our ship in pursuit thing might be tractable after all. Could just calculate a time series and run many of those, averaging...

I bet the analytical limits of Brownian motion have long been derived and could therefore be built into our code, too.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 11, 2022, 09:51:52 PM
Yeah. The lesson learned here is always do the MATH first and only then write anything, since the code turned into spaghetti because I hit constraints I had not considered beforehand (such as maximum number of shots that can be fired per second in a burst, leading to a pigeonhole sub-problem, or maximum number of shots contained in a burst overall, or that 3 shots every 5 seconds is different from 6 shots every 10 seconds from a game perspective). But now it should work just fine.

Learning Python is a good idea but will have to wait as unfortunately my work and studies only accept code in R and I haven't fully mastered R yet, so I can't really justify it to myself unlike R where I'm also learning things I can use at work. Later. This code is also recursive as the time series function calls itself possibly multiple times. But since performance is great (e.g. that matrix generated instantly) I'm not sure it needs optimizing, the performance bottleneck will not be here.

Could you elaborate a bit on the issue with alternate/linked fire? Extremely large bursts should work just fine based on testing. Will alternate/linked fire lead to a different distribution on the level of seconds? If it's a sub-second phenomenon don't need to care about it.

I think the way to handle charge up is simply to create a firing cycle using this code, then expand that to the simulation time limit (e.g. if limit is 500, and this is 10 places, then concatenate it 50 times) and add round(chargeup) zeroes to the front. Since chargeup is included in later cycles. So can use this code and add that later.

Re: Brownian motion, the problem is not the enemy ship, it's our ship in pursuit, which makes enemy movement relative to us at t=n+1 not independent from t=n making it possibly intractable analytically. Well, at least for my modest skills, since I am pretty sure it would also be possible to construct an approximation, but don't know the tools, if somebody has them let me know. Specifically the enemy ship motion over time is reduced by a non-random amount that depends on enemy ship coordinates at the end of the previous time interval and our top speed. The same applies to weapon angle. But what could be done is run a thousand models and get how weapon rotation speed affects accuracy on average numerically. But let's actually forget that for now and make the tool first since this is just a change of distributions (or in the latter case, even just adding a multiplication operation for a correction to the distribution) that can be done later if needed.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 12, 2022, 05:31:58 PM
Yeah. The lesson learned here is always do the MATH first and only then write anything, since the code turned into spaghetti because I hit constraints I had not considered beforehand (such as maximum number of shots that can be fired per second in a burst, leading to a pigeonhole sub-problem, or maximum number of shots contained in a burst overall, or that 3 shots every 5 seconds is different from 6 shots every 10 seconds from a game perspective). But now it should work just fine.

I hope it works fine...

Quote
Learning Python is a good idea but will have to wait as unfortunately my work and studies only accept code in R and I haven't fully mastered R yet, so I can't really justify it to myself unlike R where I'm also learning things I can use at work. Later. This code is also recursive as the time series function calls itself possibly multiple times. But since performance is great (e.g. that matrix generated instantly) I'm not sure it needs optimizing, the performance bottleneck will not be here.

...because if you're so pressed for time that I should have to translate the code, later discovering it to be wrong would entail either your  translating my Python into R and then changing the R, which I would have to re-translate, or you would have to change the original R, which I would have to re-translate in its entirety...  :(

With that in mind, I think your point about workflow is correct: defining the problem and studying the domain first and then writing the logic, next math, and finally code would save us time and effort.  Translating to Python should be the last step!   8)

Quote
Could you elaborate a bit on the issue with alternate/linked fire? Extremely large bursts should work just fine based on testing. Will alternate/linked fire lead to a different distribution on the level of seconds? If it's a sub-second phenomenon don't need to care about it.

Sure!  For example, consider these three weapons:

A: 1 barrel, burst size 1, 0.1 second refire delay
B: 2 ALTERNATING barrels, burst size 1, 0.1 second refire delay
C: 2 LINKED barrels, burst size 2, 0.1 second refire delay

Code that read weapon_data.csv without checking the corresponding weapon files for the number of barrels and whether they are ALTERNATING or LINKED would calculate A, B, and C to fire as many shots in as much time as one another, but a player selecting each weapon in turn and pressing fire would see A and B fire one shot every 0.1 seconds but C fire two shots every 0.1 seconds.  Writing what each barrel does might be clearer:

A
Time | Barrel 1 | Barrel 2
0.1  |   boom   |     -
0.2  |   boom   |     -
0.3  |   boom   |     -
0.4  |   boom   |     -

total: 4 shots in 0.4 seconds

B
Time | Barrel 1 | Barrel 2
0.1  |   boom   |     
0.2  |          |   boom
0.3  |   boom   |     
0.4  |          |   boom

total: 4 shots in 0.4 seconds

C
Time | Barrel 1 | Barrel 2
0.1  |   boom   |    boom
0.2  |   boom   |    boom
0.3  |   boom   |    boom
0.4  |   boom   |    boom

total: 8 shots in 0.4 seconds


That's a big difference!  :o

Quote
I think the way to handle charge up is simply to create a firing cycle using this code, then expand that to the simulation time limit (e.g. if limit is 500, and this is 10 places, then concatenate it 50 times) and add round(chargeup) zeroes to the front. Since chargeup is included in later cycles. So can use this code and add that later.

I also think that the code should treat burst size as a constraint rather than a different case, with just one sequence-builder loop for the entire function, because the only difference seems to be that non-burst fire sequences continue until the number of shots divided by the number of seconds equals the refire delay whereas burst fire sequences continue for a fixed number of shots.  Also, we would know that the length of the sequence would be the ceiling of how long firing so many shots would take plus the burst delay, if any. For example, a weapon with a burst size of 1 and refire delay of 0.37 would have to fire 37 shots, whereas a weapon with a burst size of 3 and any refire delay would have to fire 3 shots, with the refire delay being tacked onto the end.

While I just said writing Python code should be the last step, the specification I wrote was so much like code that I couldn't resist coding it.  I have tried it with even some strange numbers and gotten reasonable results.

import math
from fractions import Fraction

def firing_sequence(
        linked_barrel_count,
        charge_up_time,
        charge_down_time,
        burst_size,
        burst_delay
    ):
    burst_firing = burst_size > 1
    refire_delay = burst_delay if burst_firing else charge_down_time
    refire_delay_fraction = Fraction(1 / refire_delay).limit_denominator()
    shots_to_fire = (burst_size if burst_firing
                     else refire_delay_fraction.numerator)
    sequence_duration = charge_up_time + refire_delay_fraction.denominator
    if burst_firing: sequence_duration += charge_down_time
    sequence = [0 for _ in range(math.ceil(sequence_duration))]
    time = charge_up_time
    for shot in range(shots_to_fire):
        index = int(time)
        sequence[index] += linked_barrel_count
        time += refire_delay
    return sequence


Here's an R version.  Just add the reduced fraction function you wrote earlier. :D

firing_sequence <- function(
        linked_barrel_count,
        charge_up_time,
        charge_down_time,
        burst_size,
        burst_delay
    ) {
    burst_firing <- burst_size > 1
    if (burst_firing) {
        refire_delay <- burst_delay
    } else {
        refire_delay <- charge_down_time
    }
    refire_delay_fraction <- reduced_fraction(1 / refire_delay)
    if (burst_firing) {
        shots_to_fire <- burst_size
    } else {
        shots_to_fire <- refire_delay_fraction[1]
    }
    sequence_duration <- charge_up_time + refire_delay_fraction[2]
    if (burst_firing) {
        sequence_duration <- sequence_duration + charge_down_time
    }
    sequence <- integer(ceiling(sequence_duration))
    time <- charge_up_time
    for (shot in 1:shots_to_fire) {
        index <- floor(time) + 1
        sequence[index] <- sequence[index] + linked_barrel_count
        time <- time + refire_delay
    }
    return(sequence)
}


Quote
Re: Brownian motion, the problem is not the enemy ship, it's our ship in pursuit, which makes enemy movement relative to us at t=n+1 not independent from t=n making it possibly intractable analytically. Well, at least for my modest skills, since I am pretty sure it would also be possible to construct an approximation, but don't know the tools, if somebody has them let me know. Specifically the enemy ship motion over time is reduced by a non-random amount that depends on enemy ship coordinates at the end of the previous time interval and our top speed. The same applies to weapon angle. But what could be done is run a thousand models and get how weapon rotation speed affects accuracy on average numerically. But let's actually forget that for now and make the tool first since this is just a change of distributions (or in the latter case, even just adding a multiplication operation for a correction to the distribution) that can be done later if needed.

Agreed, let's not let the shiny math distract us until this tool is done.  ;D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 12, 2022, 10:35:26 PM
That code is clean and nice, although it doesn't produce exactly the result we want. For the "weird locust" above,
> firing_sequence(1,0,5.44,56,0.1)
[1] 11  9 10 10 11  5  0

So here it doesn't respect the maximum number of shots per second. I'm sure it can be made to do so, though.
Quote
I also think that the code should treat burst size as a constraint rather than a different case, with just one sequence-builder loop for the entire function, because the only difference seems to be that non-burst fire sequences continue until the number of shots divided by the number of seconds equals the refire delay whereas burst fire sequences continue for a fixed number of shots.  Also, we would know that the length of the sequence would be the ceiling of how long firing so many shots would take plus the burst delay, if any. For example, a weapon with a burst size of 1 and refire delay of 0.37 would have to fire 37 shots, whereas a weapon with a burst size of 3 and any refire delay would have to fire 3 shots, with the refire delay being tacked onto the end.

Here's how I see it after writing the code previously.
Let's call a1 chargeup, a2 chargedown, a firing delay (a=a1+a2), b burst size, and z burst delay. We'll ignore the initial charge up of the weapon as that can be added separately.

We generally want to write a function s such that if s(n) is our function over the integers and f(t) is the number of shots fired at timepoint t by gun f, then s(n)=integral n-1 to n f(t) dt and also there is a finite cycle c such that integral 0 to c f(t)dt=integral c to 2c f(t) dt = sum(k from 0 to c) s(k) = sum (k from c to c) s(k). Then this will have all the properties we expect. The problem is the weapon firing function is not actually always Riemann integrable. So we'll approximate what we want to do instead, as follows:

1. Case z = 0, a < 1
The number of shots fired by the weapon at any second is b/a and there are no breaks in firing, so s(n) = b/a and additionally we can easily see that
the weapon fires on average b/a shots per second, so integral(0 to c) b/a=b/a*c=sum(k from 1 to c) s(k).
2. Case z = 0, a >= 1.
The number of shots fired by the weapon is f(t)=b, when t is m*a where m is an integer, and 0 otherwise. c should be equal to a. We can't use a Riemann integral here, but we can get a reasonable representation by setting s(n)=0, otherwise and s(n)=b, n-1<m*a<n.
3. Case 1 > z > 0, a >= 1
The weapon fires b shots over a time period lasting b*z and 0 shots otherwise. Now we can see piecewise that during the burst's duration f(t)=b/(b*z), so we should have s(n) = b/(b*z)=1/z. During the period when the weapon is not firing s(n) = 0. This leaves the part when the end of the period b*z is within the period n-1, n. During this time the weapon should finish firing its burst, so it should fire the final l=b-sum(over bursting period)s(n) shots, taking l*z seconds to do so. Finally, for a cycle representation, the leftover 1-l*z should be subtracted from the next a.
4. Case z > 1. This is equivalent to case 2, above, during the bursting period, followed by a seconds where s(n) = 0.
5. Case 1 > z > 0, a < 1. The weapon again fires b shots over a time period lasting b*z and 0 shots otherwise. On average, per second, the weapon fires b/((b*z)+a) shots in nearly continuous fire, so we can set s(n) per case 1.

Finally, making sure there is a cycle c as mentioned above. This means that the function must spend an equal time firing and not firing in both the series and continuous time representation over cycle c. If a is an integer (including after the deduction of the leftover in case 3) then 1 cycle will suffice and we'll just concatenate s(n) over the cycle n from 0 to a-b*z. But if a-b*z is not an integer, then a-b*z will be rational (because this is real life computing), so a can be expressed as m+p/q where m is 0 or a positive integer and p/q are integers. We can get the cycle property by repeating the procedure for q cycles adding m zeroes, and then adding p zeroes within the cycle, because then the amount of time the gun spends resting within the cycle is qm+p=qm+q(p/q)=q(m+p/q), so the number of zeroes over the cycle is equal to q*(a-b*z), which is the desired cycle c.

That's about what the function should do to perform correctly. So it can indeed be a much simpler function than the spaghetti code I wrote.

One thing that is missing from the above is that s(n) should only be an integer. So where it would be a fraction (such as would be the case in case 5 pretty easily) then the code should instead distribute the extra shots evenly over the firing period, which is where the while loops come in (construct this by setting the vector initially to floor of the fraction and then add 1s to it until the sum is equal to the number of shots that should be fired over the period if fractional shots were allowed). However, with bursts we know s(n) needs to be 1/z and must instead add to the end of the burst. In simpler terms we know maxshots, and we know the length of time needed to produce it. So create a vector of length time, fill it with floor(maxshots/time), and add 1s until sum(vector)=maxshots. BUT if there is a burst delay then a maximum number of shots per second exists, so create a vector of length ceiling(bursttime), then fill it with 1/z, other than the last cell which should have maxshots-sum(vector without last cell). And to keep the cycle length equal, reduce next reload time by shots in last vector cell * z.

I still don't understand what the issue with the barrels is, sorry. We are only interested in total number of shots per second, so does the barrel matter? C has a burst size of two, unlike the previous weapons, so the above would calculate it to have twice the shots per second of the previous weapons, since it does not have a burst delay.

Having written all that, it does occur to me that you could probably also approach this from such as a Fourier series perspective, but it's probably more trouble than it's worth.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 13, 2022, 04:25:26 AM
That code is clean and nice, although it doesn't produce exactly the result we want. For the "weird locust" above,
> firing_sequence(1,0,5.44,56,0.1)
[1] 11  9 10 10 11  5  0

So here it doesn't respect the maximum number of shots per second. I'm sure it can be made to do so, though.

Huh, that's odd.  I tested the code and found my addition of decimals to have fallen victim to The Perils of Floating Point (https://www.lahey.com/float.htm): the floating-point time could not exactly represent the decimal it was assigned and therefore sometimes was slightly less than that value and consequently floored to indices inaccurately representing the rate of fire.  Rounding time to three decimal places upon every addition—more than enough to represent the hardcoded 0.05 second limit to refire delay—kept the variable exact enough to generate the correct sequence.

Also, I noticed I had not added charge up time to the refire delay for the non-burst case and have fixed that error.

Python
import math
from fractions import Fraction

def firing_sequence(
        linked_barrel_count,
        charge_up_time,
        charge_down_time,
        burst_size,
        burst_delay
    ):
    burst_firing = burst_size > 1
    refire_delay = burst_delay if burst_firing else charge_down_time
    refire_delay_fraction = Fraction(1 / refire_delay).limit_denominator()
    shots_to_fire = (burst_size if burst_firing
                     else refire_delay_fraction.numerator)
    sequence_duration = charge_up_time + refire_delay_fraction.denominator
    if burst_firing: sequence_duration += charge_down_time
    sequence = [0 for _ in range(math.ceil(sequence_duration))]
    time = charge_up_time
    for shot in range(shots_to_fire):
        sequence[math.floor(time)] += linked_barrel_count
        time = round(time + refire_delay, 3)
    return sequence


R
firing_sequence <- function(
        linked_barrel_count,
        charge_up_time,
        charge_down_time,
        burst_size,
        burst_delay
    ) {
    burst_firing <- burst_size > 1
    if (burst_firing) {
        refire_delay <- burst_delay
    } else {
        refire_delay <- charge_up_time + charge_down_time
    }
    refire_delay_fraction <- reduced_fraction(1 / refire_delay)
    if (burst_firing) {
        shots_to_fire <- burst_size
    } else {
        shots_to_fire <- refire_delay_fraction[1]
    }
    sequence_duration <- charge_up_time + refire_delay_fraction[2]
    if (burst_firing) {
        sequence_duration <- sequence_duration + charge_down_time
    }
    sequence <- integer(ceiling(sequence_duration))
    time <- charge_up_time
    for (shot in 1:shots_to_fire) {
        index <- floor(time) + 1
        sequence[index] <- sequence[index] + linked_barrel_count
        time <- round(time + refire_delay, digits = 3)
    }
    return(sequence)
}


Quote
Here's how I see it after writing the code previously.
Let's call a1 chargeup, a2 chargedown, a firing delay (a=a1+a2), b burst size, and z burst delay. We'll ignore the initial charge up of the weapon as that can be added separately.

We generally want to write a function s such that if s(n) is our function over the integers and f(t) is the number of shots fired at timepoint t by gun f, then s(n)=integral n-1 to n f(t) dt and also there is a finite cycle c such that integral 0 to c f(t)dt=integral c to 2c f(t) dt = sum(k from 0 to c) s(k) = sum (k from c to c) s(k). Then this will have all the properties we expect. The problem is the weapon firing function is not actually always Riemann integrable. So we'll approximate what we want to do instead, as follows:

1. Case z = 0, a < 1
The number of shots fired by the weapon at any second is b/a and there are no breaks in firing, so s(n) = b/a and additionally we can easily see that
the weapon fires on average b/a shots per second, so integral(0 to c) b/a=b/a*c=sum(k from 1 to c) s(k).
2. Case z = 0, a >= 1.
The number of shots fired by the weapon is f(t)=b, when t is m*a where m is an integer, and 0 otherwise. c should be equal to a. We can't use a Riemann integral here, but we can get a reasonable representation by setting s(n)=0, otherwise and s(n)=b, n-1<m*a<n.
3. Case 1 > z > 0, a >= 1
The weapon fires b shots over a time period lasting b*z and 0 shots otherwise. Now we can see piecewise that during the burst's duration f(t)=b/(b*z), so we should have s(n) = b/(b*z)=1/z. During the period when the weapon is not firing s(n) = 0. This leaves the part when the end of the period b*z is within the period n-1, n. During this time the weapon should finish firing its burst, so it should fire the final l=b-sum(over bursting period)s(n) shots, taking l*z seconds to do so. Finally, for a cycle representation, the leftover 1-l*z should be subtracted from the next a.
4. Case z > 1. This is equivalent to case 2, above, during the bursting period, followed by a seconds where s(n) = 0.
5. Case 1 > z > 0, a < 1. The weapon again fires b shots over a time period lasting b*z and 0 shots otherwise. On average, per second, the weapon fires b/((b*z)+a) shots in nearly continuous fire, so we can set s(n) per case 1.

Finally, making sure there is a cycle c as mentioned above. This means that the function must spend an equal time firing and not firing in both the series and continuous time representation over cycle c. If a is an integer (including after the deduction of the leftover in case 3) then 1 cycle will suffice and we'll just concatenate s(n) over the cycle n from 0 to a-b*z. But if a-b*z is not an integer, then a-b*z will be rational (because this is real life computing), so a can be expressed as m+p/q where m is 0 or a positive integer and p/q are integers. We can get the cycle property by repeating the procedure for q cycles adding m zeroes, and then adding p zeroes within the cycle, because then the amount of time the gun spends resting within the cycle is qm+p=qm+q(p/q)=q(m+p/q), so the number of zeroes over the cycle is equal to q*(a-b*z), which is the desired cycle c.

That's about what the function should do to perform correctly. So it can indeed be a much simpler function than the spaghetti code I wrote.

I hope that my updated code satisfies the specification!  I do wish we had LaTeX support on this forum to represent formulae because I feel pain and confusion when reading them as text.

Quote
One thing that is missing from the above is that s(n) should only be an integer. So where it would be a fraction (such as would be the case in case 5 pretty easily) then the code should instead distribute the extra shots evenly over the firing period, which is where the while loops come in (construct this by setting the vector initially to floor of the fraction and then add 1s to it until the sum is equal to the number of shots that should be fired over the period if fractional shots were allowed). However, with bursts we know s(n) needs to be 1/z and must instead add to the end of the burst. In simpler terms we know maxshots, and we know the length of time needed to produce it. So create a vector of length time, fill it with floor(maxshots/time), and add 1s until sum(vector)=maxshots. BUT if there is a burst delay then a maximum number of shots per second exists, so create a vector of length ceiling(bursttime), then fill it with 1/z, other than the last cell which should have maxshots-sum(vector without last cell). And to keep the cycle length equal, reduce next reload time by shots in last vector cell * z.

I think we are solving a discrete math problem, but this approach is continuous, entailing tricky conversions.

Quote
I still don't understand what the issue with the barrels is, sorry. We are only interested in total number of shots per second, so does the barrel matter? C has a burst size of two, unlike the previous weapons, so the above would calculate it to have twice the shots per second of the previous weapons, since it does not have a burst delay.

Oops, I should have written that C has a burst size of 1.  Does it make sense now?

Quote
Having written all that, it does occur to me that you could probably also approach this from such as a Fourier series perspective, but it's probably more trouble than it's worth.

 ;D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 13, 2022, 05:19:55 AM
I did some math because I realized that I am dumb and we might as well set a finite but small time instead of instantaneous fire to make this whole thing tractable without weird loops and all that. Here is what I got and let me tell you I should have just written this in the first place instead of spending hours writing spaghetti code. Well, live and learn.

(https://i.ibb.co/yYbT0cw/image.png) (https://ibb.co/8dS3MLW)

Now we should be just able to implement this calculation and round the result to integers and get exactly what we want without any funny business or computer hacking. I'll try it later, can't now.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 13, 2022, 08:12:59 AM
Wait, though, doesn't the code I wrote work?  ???  Calling a numerical solver on a discrete function converted to a continuous one would still entail weird loops, computer hacking, etc., but rather done by someone else under the hood.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 13, 2022, 08:49:15 AM
It for sure may work, sorry. I got excited about an opportunity to solve the problem more mathematically and didn't test. Will later given opportunity.

Theoretically the advantage to this, if it works, is that it satisfies s(n)=integral(n-1 to n)f(t)dt exactly while the previous is approximately over a cycle. This approach should yield the bursts also starting in fractional time and not only ending so, in other words, and include chargeup in the same function.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 13, 2022, 09:55:53 AM
Alright so upon testing your new code it now produces the correct result for "weird locust", although not the correct chargedown time
> #weird locust
> firing_sequence(1,0,5.44,56,0.1)
[1] 10 10 10 10 10  6  0

However, it can't handle a large burst
> firing_sequence(1,0,5.44,300,0.1)
 [1] 10 10 10 10 10 10 10 NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA

> #weird locust
> firing_sequence(1,1,5,5,1)
[1] 0 1 1 1 1 1 0
> #weird locust
> firing_sequence(1,1,5,5,1.2)
 [1] 0 1 1 1 1 1 0 0 0 0 0 0

This result also doesn't seem correct to me as it should only take 1 second extra. This is a very tricky thing to code for sure. I'll try to write my new solution into code in a bit.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 13, 2022, 01:22:16 PM
Alright here's some code. It's fairly clean with this method. I notice there were some hidden assumptions in my math (such as that there is at least 1 midpoint in the interval) that I had to work through while coding. This code is still partial in that it does not include the part when cycle < 1.
R code
Code
#supremum of n in vector 
sup <- function(n, vector) return(min(vector[vector>=n]))
#infimum of n in vector
inf <- function(n, vector) return(max(vector[vector<=n]))


#the math behind this tool is given in https://fractalsoftworks.com/forum/index.php?topic=25536.75

#general parameters
timelimit <- 500
#delta means how long instantaneous firing takes to happen. Do not set too low to avoid calculation issues,
#or too high to avoid distorting results
delta <- 0.001

firing_cycle <- function(chargeup,chargedown,burstsize,burstdelay){
 
  #0. use the same notation as in the mathematical proof
  a1 <- chargeup
  #cooldown
  a <- chargeup+chargedown
  b <- burstsize
  #1. calculate z
  if(burstdelay==0) z <- delta/burstsize else z <- burstdelay
  #2. calculate cycle length
  c <- a+b*z
  #3. deal with trivial degenerate cases
  #3.1. the firing cycle is exactly 1 second
  if(c==1){
    vector <- vector(mode="double", length=timelimit)
    for (x in 1:floor(a1)) vector[x] <- 0
    vector[ceiling(a1)] <- (a1-floor(a1))*b
    for (x in ((ceiling(a1)+1):timelimit)) vector[x] <- b
    return(vector)
  }
 
  #4. calculate how long the firing cycle should be. If we can reach an even number
  #in less than timelimit, use that, else timelimit
  cycleremainder <- c - floor(c)
  cyclelength <- 0
  if(cycleremainder==0) cyclelength <- c
  if(cycleremainder > 0) cyclelength <- c*1/cycleremainder
  cyclelength <- max(timelimit, cyclelength)
 
  #5. calculate lower points, midpoints and upper points for the geometric integral
  M <- vector(mode="double", length=0)
  m <- 0
  index <- 0
  while(m < cyclelength){
    m <- a1+b*z/2+index*c
    M <- c(M,m)
    index <- index+1
  }
  L <- M-b*z/2
  U <- M+b*z/2
  vector <- vector(mode="double", length=cyclelength)
  #6. case c > 1
  if(c>1){
    for (x in 1:cyclelength){
      m <- sup(x-1, M)
      if(length(U[U <= x]) != 0) u <- sup(m, U) else u <- min(U)
      l <- inf(m, L)
      C <- 0
      D <- 0
      #if there is a midpoint in the interval
      mpresent <- 0
      if(m>=x-1 & m<=x) mpresent <- 1
      if(mpresent==1) vector[x] <- min(x,u)-max(x-1,l)
      #if there is no midpoint in the interval there still might be a lower bound, if we are below m
      if(mpresent==0) vector[x] <- vector[x]+max(0,min(1,x-l))
      #finally, we might be above the previous midpoint but within its upperbound
      prevm <- (inf(x-1,M))
      if(length(U[U <= prevm]) != 0){
        prevu <- sup(prevm, U)
        if(mpresent==0) vector[x] <- vector[x]+max(0,min(1,prevu-(x-1)))
      } else {
        if(mpresent==0 & x > M[1]) vector[x] <- vector[x]+max(0,min(1,U[1]-(x-1)))
      }
      vector[x] <- 1/z*vector[x]
    }
    return(round(vector,0))
  }
}

[close]

Output

#weird locust
> firing_cycle(0,5.44,56,0.1)
  [1] 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0  9 10 10 10 10  7  0  0  0  0  0  9
 [35] 10 10 10 10  7  0  0  0  0  0  8 10 10 10 10  8  0  0  0  0  0  8 10 10 10 10  8  0  0  0  0  0  8 10
 [69] 10 10 10  8  0  0  0  0  0  7 10 10 10 10  9  0  0  0  0  0  7 10 10 10 10  9  0  0  0  0  0  6 10 10
[103] 10 10 10  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0  5 10 10 10
[137] 10 10  1  0  0  0  0  5 10 10 10 10 10  1  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  4 10 10 10 10
[171] 10  2  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  3 10 10 10 10 10  3  0  0  0  0  3 10 10 10 10 10
[205]  3  0  0  0  0  2 10 10 10 10 10  4  0  0  0  0  2 10 10 10 10 10  4  0  0  0  0  2 10 10 10 10 10  4
[239]  0  0  0  0  1 10 10 10 10 10  5  0  0  0  0  1 10 10 10 10 10  5  0  0  0  0  0 10 10 10 10 10  6  0
[273]  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0  9 10 10 10 10  7  0  0
[307]  0  0  0  9 10 10 10 10  7  0  0  0  0  0  8 10 10 10 10  8  0  0  0  0  0  8 10 10 10 10  8  0  0  0
[341]  0  0  8 10 10 10 10  8  0  0  0  0  0  7 10 10 10 10  9  0  0  0  0  0  7 10 10 10 10  9  0  0  0  0
[375]  0  6 10 10 10 10 10  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0
[409]  5 10 10 10 10 10  1  0  0  0  0  5 10 10 10 10 10  1  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  4
[443] 10 10 10 10 10  2  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  3 10 10 10 10 10  3  0  0  0  0  3 10
[477] 10 10 10 10  3  0  0  0  0  2 10 10 10 10 10  4  0  0  0  0  2 10 10 10



#weird-er locust, with initial firing delay
> firing_cycle(3.87,1.34,384,0.01)
  [1]   0   0   0  13 100 100 100  71   0   0   0   0   8 100 100 100  76   0   0   0   0   3 100 100 100  81
 [27]   0   0   0   0   0  98 100 100  86   0   0   0   0   0  93 100 100  91   0   0   0   0   0  88 100 100
 [53]  96   0   0   0   0   0  83 100 100 100   1   0   0   0   0  78 100 100 100   6   0   0   0   0  73 100
 [79] 100 100  11   0   0   0   0  68 100 100 100  16   0   0   0   0  63 100 100 100  21   0   0   0   0  58
[105] 100 100 100  26   0   0   0   0  53 100 100 100  31   0   0   0   0  48 100 100 100  36   0   0   0   0
[131]  43 100 100 100  41   0   0   0   0  38 100 100 100  46   0   0   0   0  33 100 100 100  51   0   0   0
[157]   0  28 100 100 100  56   0   0   0   0  23 100 100 100  61   0   0   0   0  18 100 100 100  66   0   0
[183]   0   0  13 100 100 100  71   0   0   0   0   8 100 100 100  76   0   0   0   0   3 100 100 100  81   0
[209]   0   0   0   0  98 100 100  86   0   0   0   0   0  93 100 100  91   0   0   0   0   0  88 100 100  96
[235]   0   0   0   0   0  83 100 100 100   1   0   0   0   0  78 100 100 100   6   0   0   0   0  73 100 100
[261] 100  11   0   0   0   0  68 100 100 100  16   0   0   0   0  63 100 100 100  21   0   0   0   0  58 100
[287] 100 100  26   0   0   0   0  53 100 100 100  31   0   0   0   0  48 100 100 100  36   0   0   0   0  43
[313] 100 100 100  41   0   0   0   0  38 100 100 100  46   0   0   0   0  33 100 100 100  51   0   0   0   0
[339]  28 100 100 100  56   0   0   0   0  23 100 100 100  61   0   0   0   0  18 100 100 100  66   0   0   0
[365]   0  13 100 100 100  71   0   0   0   0   8 100 100 100  76   0   0   0   0   3 100 100 100  81   0   0
[391]   0   0   0  98 100 100  86   0   0   0   0   0  93 100 100  91   0   0   0   0   0  88 100 100  96   0
[417]   0   0   0   0  83 100 100 100   1   0   0   0   0  78 100 100 100   6   0   0   0   0  73 100 100 100
[443]  11   0   0   0   0  68 100 100 100  16   0   0   0   0  63 100 100 100  21   0   0   0   0  58 100 100
[469] 100  26   0   0   0   0  53 100 100 100  31   0   0   0   0  48 100 100 100  36   0   0   0   0  43 100
[495] 100 100  41   0   0   0


Now as you can see there is still an error in the first cycle when we don't have a "previous u". Will need to work on that. Edit:got the first cycle part fixed, edited code to fixed version. Now just needs case c < 1.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Dri on November 13, 2022, 02:19:29 PM
Why does the Conquest do this? No other ship spawns so many balance discussions as the Conquest does. What's the deal?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 13, 2022, 07:53:21 PM
Alright here's some code. It's fairly clean with this method. I notice there were some hidden assumptions in my math (such as that there is at least 1 midpoint in the interval) that I had to work through while coding. This code is still partial in that it does not include the part when cycle < 1.

Now as you can see there is still an error in the first cycle when we don't have a "previous u". Will need to work on that. Edit:got the first cycle part fixed, edited code to fixed version. Now just needs case c < 1.

Woaaaaah.  :D  This is awesome.  My only reply regards formatting: please use full variable names rather than substituting single letters for them.  Unlike on paper, we have copy-and-paste to save time and unlimited lines for our calculations!  Also, please put spaces before and after operators, after commas, and between if and parentheses.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 14, 2022, 01:58:59 AM
Alright, I was able to finish the code while commuting on the bus. Here it is.

Code
#supremum of n in vector 
sup <- function(n, vector) return(min(vector[vector>=n]))
#infimum of n in vector
inf <- function(n, vector) return(max(vector[vector<=n]))
#for this specific application we want a particular form of rounding where every other 0.5 is rounded up
#and every other 0.5 is rounded down
special_round <- function(vector){
  flipflop <- 0
  for(x in 1:length(vector)){
    if ((vector[x] - floor(vector[x])) == 0.5){
      if (flipflop %% 2 == 0){
        vector[x] <- ceiling(vector[x])
      flipflop <- flipflop + 1
    } else {
      vector[x] <- floor(vector[x])
      flipflop <- flipflop + 1
    }
    }
  }
  return(round(vector,0))
}



#the math behind this tool is given in https://fractalsoftworks.com/forum/index.php?topic=25536.75

#general parameters
timelimit <- 500
#delta means how long instantaneous firing takes to happen. Do not set too low to avoid calculation issues,
#or too high to avoid distorting results
delta <- 0.001

firing_cycle <- function(chargeup,chargedown,burstsize,burstdelay){
 
  #0. use the same notation as in the mathematical proof
  a1 <- chargeup
  #cooldown
  a <- chargeup+chargedown
  b <- burstsize
  #1. calculate z
  if (burstdelay==0) z <- delta/burstsize else z <- burstdelay
  #2. calculate cycle length
  c <- a+b*z
  #3. deal with trivial degenerate cases
  #3.1. the firing cycle is exactly 1 second
  if (c==1){
    vector <- vector(mode="double", length=timelimit)
    for (x in 1:floor(a1)) vector[x] <- 0
    vector[ceiling(a1)] <- (a1-floor(a1))*b
    for (x in ((ceiling(a1)+1):timelimit)) vector[x] <- b
    return(special_round(vector))
  }
 
  #4. calculate how long the firing cycle should be. If we can reach an even number
  #in less than timelimit, use that, else timelimit
  cycleremainder <- c - floor(c)
  cyclelength <- 0
  if (cycleremainder==0) cyclelength <- c
  if (cycleremainder > 0) cyclelength <- c*1/cycleremainder
  cyclelength <- max(timelimit, cyclelength)
 
  #5. calculate lower points, midpoints and upper points for the geometric integral
  M <- vector(mode="double", length=0)
  m <- 0
  index <- 0
  while(m < cyclelength){
    m <- a1+b*z/2+index*c
    M <- c(M,m)
    index <- index+1
  }
  L <- M-b*z/2
  U <- M+b*z/2
  vector <- vector(mode="double", length=cyclelength)
  #6. case c > 1
  if (c>1){
    for (x in 1:cyclelength){
      m <- sup(x-1, M)
      if (length(U[U <= x]) != 0) u <- sup(m, U) else u <- min(U)
      l <- inf(m, L)
      #if there is a midpoint in the interval
      mpresent <- 0
      if (m>=x-1 & m<=x) mpresent <- 1
      if (mpresent==1) vector[x] <- min(x,u)-max(x-1,l)
      #if there is no midpoint in the interval there still might be a lower bound, if we are below m
      if (mpresent==0) vector[x] <- vector[x]+max(0,min(1,x-l))
      #finally, we might be above the previous midpoint but within its upperbound
      prevm <- (inf(x-1,M))
      if (length(U[U <= prevm]) != 0){
        prevu <- sup(prevm, U)
        if (mpresent==0) vector[x] <- vector[x]+max(0,min(1,prevu-(x-1)))
      } else {
        if (mpresent==0 & x > M[1]) vector[x] <- vector[x]+max(0,min(1,U[1]-(x-1)))
      }
      vector[x] <- 1/z*vector[x]
    }
    return(special_round(vector))
  }
  #7. case c < 1
  if (c<1) {
    for (x in 1:cyclelength){
      #there is at least one and possibly many midpoints within the interval
      #compute how many midpoints there are in the interval
      n_m <- length(M[M >= x-1 & M <= x])
      #case there is only 1 midpoint in the interval
      if (n_m == 1){
        m <- sup(x-1, M)
        if (length(U[U <= x]) != 0) u <- sup(m, U) else u <- min(U)
        l <- inf(m, L)
        #the part around the midpoint
        vector[x] <- vector[x] + min(x,m+b*z/2)-max(x-1,m-b*z/2)
        #there might be a lower point of the next firing interval within the interval
        nextl <- sup(m, L)
        vector[x] <- vector[x]+max(0,min(1,x-nextl))
        #there might be an upper point of the previous firing interval within the interval
        if (length(U[U <= m]) != 0){
          prevu <- inf(m, U)
          vector[x] <- vector[x]+max(0,min(1,prevu-(x-1)))
        } else {
          if (x-1 > M[1]) vector[x] <- vector[x]+max(0,min(1,U[1]-(x-1)))
        }
      }
     
      #case there are 2 or more midpoints in the interval
      if (n_m >= 2){
        #compute the highest and the lowest
        m1 <- sup(x-1, M)
        m2 <- inf(x, M)
        if (length(U[U <= m2]) != 0) u <- sup(m2, U) else u <- min(U)
        l <- inf(m1, L)
        #the part around the lowest midpoint
        vector[x] <- vector[x] + sup(m1,U)-max(x-1,l)
        #the part around the uppermost midpoint
        vector[x] <- vector[x] + min(x,u)-inf(m2,L)
        #any extra midpoints
        vector[x] <- vector[x] + max(0,n_m-2)*b*z
        #there might be a lower point of the next firing interval within the interval
        nextl <- sup(m2, L)
        vector[x] <- vector[x]+max(0,min(1,x-nextl))
        #there might be an upper point of the previous firing interval within the interval
        if (length(U[U <= m1]) != 0){
          prevu <- inf(m1, U)
          vector[x] <- vector[x]+max(0,min(1,prevu-(x-1)))
        } else {
          if (x - 1 > M[1]) vector[x] <- vector[x]+max(0,min(1,U[1]-(x-1)))
        }
      }
     
     
      vector[x] <- 1/z*vector[x]
    }
    return(special_round(vector))
  }
 
}

Now it can handle even very densely firing weapons like

> firing_cycle(0.5,0,10,.01)
  [1] 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10
 [35] 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20
 [69] 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20
[103] 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10
[137] 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20
[171] 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20
[205] 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10
[239] 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20
[273] 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20
[307] 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10
[341] 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20
[375] 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20
[409] 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10
[443] 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20
[477] 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20 20 10 20



> firing_cycle(0.1,0,10,.1)
  [1]  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9
 [35]  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8
 [69]  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7
[103]  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6
[137]  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5
[171]  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5
[205]  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6  7  8  9 10  9  8  7  6  5  5  6
[239]  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7
[273]  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8
[307]  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9
[341] 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10
[375]  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9
[409]  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8
[443]  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7
[477]  6  9  5  6  7  8  9 10  9  8  7  6  9  5  6  7  8  9 10  9  8  7  6  9


I'm not sure that it would improve code readability to substitute descriptive variable names, sorry. The current names relate to the mathematical description of computing the integral geometrically.

Basically, if you know what computing the integral geometrically means and can understand the math, then something like
#l is the infimum of m, l in L
l <- inf(m, L)
vector <- vector + max(x-1,l)

is, at least to me, much more readable and easy to make sure the program is doing what it should be doing than
HighestLowerBoundBelowm <- HighestLowerBound(MiddlePoint, LowerBoundVector)
TimeSeriesVector <- TimeSeriesVector + max(IntervalUpperBound-1, HighestLowerBoundBelowm)

which just makes my head ache, and if you can't read the equations then how would you know whether the program is doing what it's supposed to be doing anyway? It's highly unintuitive if you don't know the integral behind it that this program should produce what it does.

You are the better programmer of course and feel free to do it differently if you end up translating this to python. Explanation of one letter variables:
a1 - chargeup
a - cooldown (chargeup+chargedown)
b - burst size
c - cycle length (firing time + cooldown)
M - vector storing middle points of firing cycle
L - vector storing lower bounds of firing cycle
U - vector storing upper bounds of firing cycle
u - upper bound of firing cycle immediately above m (m2)
l - lower bound of firing cycle immediately below m (m1)
m - lowest middle point above x-1
prevu - highest upper bound below m

z - burst delay

One problem still not addressed is that for very large burst delay (say, z=4 or more) 1/z will be so low that rounding the results from this approach will produce a vector full of zeroes instead of what we want (ie. while it returns the correct distribution you can no longer convert it to integers by simply rounding). There should be a switch to revert to the previously written function in that case, or, alternatively, a function to re-compute the distribution with a lower z and insert zeroes to match the original (seemingly not that challenging to write). But before adding more code, do you think this is a thing that actually exists that people make weapons with burst delay 4?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 14, 2022, 09:27:03 AM
Alright, I was able to finish the code while commuting on the bus. Here it is.

Wooo!  Also, you have to commute on the bus?  Bummer.  Remote all the way!  8)

Quote
I'm not sure that it would improve code readability to substitute descriptive variable names, sorry. The current names relate to the mathematical description of computing the integral geometrically.

Basically, if you know what computing the integral geometrically means and can understand the math,

Ah, but that's just the problem.  I suspect you have assumed "computing the integral geometrically" to have one common meaning, but many approaches exist.  Readers could see which one the code implements only by reading it, and even if they knew which one, could not know what each abbreviation or single letter meant beyond common formulae (e.g., c = sqrt(a^2 + b^2)) without checking the documentation, which might have become useless or misleading unless it had been diligently updated with every code change.

Quote
then something like
#l is the infimum of m, l in L
l <- inf(m, L)
vector <- vector + max(x-1,l)

is, at least to me, much more readable and easy to make sure the program is doing what it should be doing than
HighestLowerBoundBelowm <- HighestLowerBound(MiddlePoint, LowerBoundVector)
TimeSeriesVector <- TimeSeriesVector + max(IntervalUpperBound-1, HighestLowerBoundBelowm)

Indeed, variable names should not be exhaustive descriptions but just long enough to describe their variables in their context.  :)  Occasionally, one can use abbreviations and acronyms to shorten otherwise long variable names (e.g., log instead of logarithm, RPM instead of revolutions_per_minute) and single letters for immediately-clear math and iteration (e.g., f(x) = x^2, for (i in 1:10)).  Here's what I had in mind, though I wish I had a name for c and z.

#supremum of n in cycle 
supremum <- function(n, cycle) return(min(cycle[cycle >= n]))
#infimum of n in cycle
infimum <- function(n, cycle) return(max(cycle[cycle <= n]))
#for this specific application we want a particular form of
#rounding where every other 0.5 is rounded up
#and every other 0.5 is rounded down
special_round <- function(cycle) {
  flipflop <- 0
  for(t in 1:length(cycle)) {
    if ((cycle[t] - floor(cycle[t])) == 0.5) {
      if (flipflop %% 2 == 0) {
        cycle[t] <- ceiling(cycle[t])
        flipflop <- flipflop + 1
      } else {
        cycle[t] <- floor(cycle[t])
        flipflop <- flipflop + 1
      }
    }
  }
  return(round(cycle, 0))
}



#the math behind this tool is given in
#https://fractalsoftworks.com/forum/index.php?topic=25536.75

#general parameters
time_limit <- 500
#delta means how long instantaneous firing takes to happen.
#Do not set too low to avoid calculation issues,
#or too high to avoid distorting results
delta <- 0.001

firing_cycle <- function(chargeup, chargedown, burst_size, burst_delay) {
 
  chargeup <- chargeup
  refire_delay <- chargeup + chargedown
  if (burst_delay == 0) burst_delay <- delta / burstsize
  #calculate cycle length
  duration <- refire_delay + burst_size * burst_delay
  #degenerate case of 1 second firing cycle
  if (duration == 1) {
    cycle <- cycle(mode = "double",  length = time_limit)
    for (t in 1:floor(chargeup)) cycle[t] <- 0
    cycle[ceiling(chargeup)] <- (chargeup - floor(chargeup)) * burst_size
    for (t in ((ceiling(chargeup) + 1):time_limit)) cycle[t] <- burst_size
    return(special_round(cycle))
  }
 
  #calculate how long the firing cycle should be.
  #If we can reach an even number in less than time_limit,
  #use that, else time_limit
  remainder <- duration - floor(duration)
  duration <- 0
  if (remainder == 0) duration <- duration
  if (remainder > 0) duration <- duration * 1 / remainder
  duration <- max(time_limit, duration)
 
  #calculate lower points, midpoints and upper points
  #for the geometric integral
  midpoints <- cycle(mode="double", length=0)
  midpoint <- 0
  index <- 0
  while (midpoint < duration) {
    midpoint <- chargeup + burst_size * burst_delay / 2 + index * duration
    midpoints <- c(midpoints, midpoint)
    index <- index + 1
  }
  lower_bounds <- midpoints - burst_size * burst_delay / 2
  upper_bounds <- midpoints + burst_size * burst_delay / 2
  cycle <- cycle(mode="double", length=duration)

  if (duration > 1) {
    for (t in 1:duration) {
      midpoint <- supremum(t - 1, midpoints)
      if (length(upper_bounds[upper_bounds <= t]) != 0) {
        upper_bound <- supremum(midpoint, upper_bounds)
      } else {
        upper_bound <- min(upper_bounds)
      }
      lower_bound <- infimum(midpoint, lower_bounds)
      #if there is a midpoint in the interval
      midpoint_present <- 0
      if (midpoint >= t - 1 & midpoint <= t) midpoint_present <- 1
      if (midpoint_present == 1) {
        cycle[t] <- min(t, upper_bound) - max(t - 1, lower_bound)
      }
      #if there is no midpoint in the interval there
      #still might be a lower bound, if we are below
      #midpoint
      if (midpoint_present == 0) {
        cycle[t] <- cycle[t] + max(0, min(1, t - lower_bound))
      }
      #finally, we might be above the previous midpoint
      #but within its upperbound
      previous_midpoint <- (infimum(t - 1, midpoints))
      if (length(upper_bounds[upper_bounds <= previous_midpoint]) != 0) {
        previous_upper_bound <- supremum(previous_midpoint, upper_bounds)
        if (midpoint_present == 0) {
          cycle[t] <- cycle[t] + max(0, min(1, previous_upper_bound - (t - 1)))
        }
      } else if (midpoint_present == 0 & t > midpoints[1]) {
        cycle[t] <- cycle[t] + max(0, min(1, upper_bounds[1] - (t - 1)))
      }
      cycle[t] <- 1 / burst_delay * cycle[t]
    }
  } else if (duration < 1) {
    for (t in 1:duration) {
      #there is at least one and possibly many
      #midpoints within the interval compute how many
      #midpoints there are in the interval
      midpoint_count <- length(midpoints[midpoints >= t - 1 & midpoints <= t])
     
      if (midpoint_count == 1) {
        midpoint <- supremum(t - 1,  midpoints)
        if (length(upper_bounds[upper_bounds <= t]) != 0) {
          upper_bound <- supremum(midpoint,  upper_bounds)
        } else {
          upper_bound <- min(upper_bounds)
        }
        lower_bound <- infimum(midpoint,  lower_bounds)
        #the part around the midpoint
        cycle[t] <- (cycle[t]
                    + min(t, midpoint + burst_size * burst_delay / 2)
                    - max(t - 1, midpoint-burst_size * burst_delay / 2))
      } else if (midpoint_count > 1) {
        #compute the highest and the lowest
        highest_midpoint <- supremum(t - 1, midpoints)
        lowest_midpoint <- infimum(t, midpoints)
        if (length(upper_bounds[upper_bounds <= lowest_midpoint]) != 0) {
          upper_bound <- supremum(lowest_midpoint,  upper_bounds)
        } else {
          upper_bound <- min(upper_bounds)
        }
        lower_bound <- infimum(highest_midpoint,  lower_bounds)
        #the part around the lowest midpoint
        cycle[t] <- cycle[t]
                    + supremum(highest_midpoint, upper_bounds)
                    - max(t - 1, lower_bound)
        #the part around the uppermost midpoint
        cycle[t] <- cycle[t]
                    + min(t, upper_bound)
                    - infimum(lowest_midpoint, lower_bounds)
        #any extra midpoints
        cycle[t] <- (cycle[t]
                    + max(0, midpoint_count - 2)
                    * burst_size
                    * burst_delay)
      }
      #there might be a lower point of the next
      #firing interval within the interval
      next_lowest_point <- supremum(midpoint,  lower_bounds)
      cycle[t] <- cycle[t] + max(0, min(1, t - next_lowest_point))
      #there might be an upper point of the previous
      #firing interval within the interval
      if (length(upper_bounds[upper_bounds <= midpoint]) != 0) {
        previous_upper_bound <- infimum(midpoint,  upper_bounds)
        cycle[t] <- cycle[t] + max(0, min(1, previous_upper_bound - (t - 1)))
      } else if (t - 1 > midpoints[1]) {
        cycle[t] <- cycle[t] + max(0, min(1, upper_bounds[1] - (t - 1)))
      }
      cycle[t] <- 1 / burst_delay * cycle[t]
    }
  }
  return(special_round(cycle))
}



Quote
which just makes my head ache, and if you can't read the equations then how would you know whether the program is doing what it's supposed to be doing anyway? It's highly unintuitive if you don't know the integral behind it that this program should produce what it does.

While the reader must understand midpoint integration to understand the code, reading a variable name would be easier than having to check this table, especially when some of the variables already have names but were given one-letter ones.

Quote
You are the better programmer of course and feel free to do it differently if you end up translating this to python. Explanation of one letter variables:
a1 - chargeup
a - cooldown (chargeup+chargedown)
b - burst size
c - cycle length (firing time + cooldown)
M - vector storing middle points of firing cycle
L - vector storing lower bounds of firing cycle
U - vector storing upper bounds of firing cycle
u - upper bound of firing cycle immediately above m (m2)
l - lower bound of firing cycle immediately below m (m1)
m - lowest middle point above x-1
prevu - highest upper bound below m
z - burst delay

Here are the names I used (albeit snake_case).   They are one word if possible, two words if necessary, and only one has three words.

a1 - chargeup
a - cooldown
b - burst_size
c - duration
M - midpoints
L - lower_bounds
U - upper_bounds
u - upper_bound
l - lower_bound
m - midpoint
prevu - previous_upper_bound
z - burst_delay

Quote
One problem still not addressed is that for very large burst delay (say, z=4 or more) 1/z will be so low that rounding the results from this approach will produce a vector full of zeroes instead of what we want (ie. while it returns the correct distribution you can no longer convert it to integers by simply rounding). There should be a switch to revert to the previously written function in that case, or, alternatively, a function to re-compute the distribution with a lower z and insert zeroes to match the original (seemingly not that challenging to write). But before adding more code, do you think this is a thing that actually exists that people make weapons with burst delay 4?

We could just slap a warning label on the box.  That said, I fear this strange behavior and the duck tape needed to fix it are the consequences of a continuous approach to a discrete problem, which I wish could be avoided by not needing integer integer buckets of shots.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 14, 2022, 09:45:18 AM
Well, I can't prove it right now, and I don't have access to a computer, but it occurs to me that maybe it's just special_round(z*vector) when z>1. That's because an interval can't contain more than 1 shot under any circumstances if z > 1, and intervals containing 1 shot will have value 1/z.

If not though, and it's actually probably not because that would mean that sparse and very sparse bursts have the same density, then it's still an easy fix.

Essentially what we do is if z is >1 we compute the distribution as if z=1, then insert a zero every 1/z cells.

Another option is to pool the values of z adjacent cells when z>1 into one cell. (How to pool every 1.2 cells: imagine a 1/10 lattice on top and pool every 12 cells)

Last approach is prob easiest. So essentially here's how to write it (I can do it on the bus some other time)
1. create a vector that is 10x length of vector to be pooled
2. give cells value of 1/10 of the value of cell(floor(x/10)) of the original vector, where x is the index in the new vector
3. over a cycle of round to 1 decimal z * 10, set cells to 0 other than the/a middle cell which is set to sum(cells)
4. set value of original vector at index y to sum of cells of new vector from y*10 to y*10+9.
5. return with special rounding as usual

That should solve it with enough precision.

Doing this with integers over discrete time is going to be exactly the strength of this program, as it's very difficult analytically (how do you calculate the armor damage reduction?) and the computing time would be soul crushingly long using something like increments of 0.01s for what this program is supposed to do which is run something like let's say an ensemble of 8 weapons from a set of 20 for each slot, for a total of 25 600 000 000 separate model combats.

Edit to add: your code looks really good! But I'm a little confused by the two uses of "duration", seemingly both for length of 1 weapon cycle ("c") and of the whole vector we are building ("cyclelength") - okay, I see why that was a poor name. And apparently I used the same wording for both in the comments too - sorry.

Might just go vector length = time limit as that would simplify the code for the main weapon model later (removes the need for modulo operations), our concern probably isn't memory but speed.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 14, 2022, 11:32:56 AM
Well, I can't prove it right now, and I don't have access to a computer, but it occurs to me that maybe it's just special_round(z*vector) when z>1. That's because an interval can't contain more than 1 shot under any circumstances if z > 1, and intervals containing 1 shot will have value 1/z.

If not though, and it's actually probably not because that would mean that sparse and very sparse bursts have the same density, then it's still an easy fix.

Essentially what we do is if z is >1 we compute the distribution as if z=1, then insert a zero every 1/z cells.

Another option is to pool the values of z adjacent cells when z>1 into one cell. (How to pool every 1.2 cells: imagine a 1/10 lattice on top and pool every 12 cells)

Last approach is prob easiest. So essentially here's how to write it (I can do it on the bus some other time)
1. create a vector that is 10x length of vector to be pooled
2. give cells value of 1/10 of the value of cell(floor(x/10)) of the original vector, where x is the index in the new vector
3. over a cycle of round to 1 decimal z * 10, set cells to 0 other than the middle cell which is set to sum(cells)
4. set value of original vector at index y to sum of cells of new vector from y*10 to y*10+9.
5. return with special rounding as usual

That should solve it with enough precision.

Doing this with integers over discrete time is going to be exactly the strength of this program, as it's very difficult analytically (how do you calculate the armor damage reduction?) and the computing time would be soul crushingly long using something like increments of 0.01s for what this program is supposed to do which is run something like let's say an ensemble of 8 weapons from a set of 20 for each slot, for a total of 25 600 000 000 separate model combats.

Hm, what if we somehow went shot-by-shot instead of increment-by-increment of time?  I suppose we might take a while for a ship with many fast-firing weapons.  If we held down the trigger and marked every time it fired until it got 'close enough' to its average DPS, we could save the marked times into an array for each weapon.  Then we could find the weapon with the 'soonest' next shot, 'advance' time to it, and repeat, cycling over the arrays as needed.  It might not be faster, but it would also spare you solving this nasty integer problem.   :-\

~10^7 combats sounds like more than needed for that number of weapons and slots because ships usually have repeated slots, especially small slots, which are usually of the same type; e.g., 3x Small Ballistic and 1x Medium Ballistic on the Cerberus.  With 20 weapons of each of the nine slot types {small, medium, large; ballistic, missile, energy} the number of weapon combinations would be:

(3 x small ballistic combinations) * medium ballistic possibilities

(3 choose 20) * 20

1,140 * 20

22,800

The number of permutations would be about eight times larger, and if the Cerberus had 5x small ballistics and 1 medium ballistic, then the difference would be 40 times.  Using combinations instead of permutations would save tons of time.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 14, 2022, 11:46:39 AM
If your goal is just to look for the best loadout, brute force searching every possible loadout (or some very large number) is probably not the best course of action. IMO, better to treat it as an optimization problem and try something like a genetic algorithm or a particle swarm optimizer. That's actually something I've wanted to do for a while, but never got around to.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 14, 2022, 11:50:53 AM
If your goal is just to look for the best loadout, brute force searching every possible loadout (or some very large number) is probably not the best course of action. IMO, better to treat it as an optimization problem and try something like a genetic algorithm or a particle swarm optimizer. That's actually something I've wanted to do for a while, but never got around to.

We could do that.  Hey CapnHector, how about it?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 14, 2022, 12:00:40 PM
Good to have you intrinsic_parity. Combinations are what I did in the OP and you can brute force those for one ship but it is certainly not optimal. But what about answering a question like "does this mod contain any weapons that are overpowered?"

Anyway we'll cross that bridge when we get to it. You are going to need a parameter for time no matter what you do, because the enemy ship has shields and flux that are affected by time in a linear way. But I think this problem should basically be tamed by "re-discretizing" the shots by pooling cells. If their timing is off by some fraction of a second as a result that shouldn't matter. I don't think there should even be many weapons with over 1 second burst delay, because why would you ever do that? It should almost never count shots wrong, because both edges of the burst that are in the pooling range can't be over 50% of the pooled cells (since burst length is bz and the pooling width is z).

This still needs to be able to handle beams and regenerating ammo by the way, so we're not done here yet. But it's good enough to work on other things for a while if desired.

Adding ammo should be easy, it can be performed after calculating the vector. Just keep track of shots and set cells to zero after exceeding ammo, then add a 1 to those cells every regenrate cells. Haven't looked at beams yet.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 14, 2022, 12:20:54 PM
Good to have you intrinsic_parity. Permutations are what I did in the OP and you can brute force those for one ship but it is certainly not optimal. But what about answering a question like "does this mod contain any weapons that are overpowered?"
I can think of ways of answering that question, but I agree that it's a future problem.

Also, the game itself is simulated discretely, so there should be an underlying 'tick rate', and all the rate of fires should be multiples of that (I think). I feel like you should just use that tick rate for your simulation, I think that would solve the problems you're having (although I'm not 100% sure since I haven't been following the code very closely). That would also make beams pretty straight forward afaik.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 14, 2022, 12:35:15 PM
Actually we would always run into this issue regardless of scale, because of the method I used to solve the number of shots over time, which was treating it as boxes of height shots per second and width firing duration and then geometrically solving the definite integral of shot number from timepoint-1 to timepoint.

The way to avoid this problem and still get the same result would be simply to calculate the timepoint of each shot of the weapon (it goes chargeup-shot-burstdelay-shot-burstdelay...chargedown-chargeup-shot-burstdelay so by no means that hard) and then calculate how many of these fall between t-1 and t.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 14, 2022, 01:12:22 PM
My point was that if you use the exact discretization that game uses, then all of the shots will happen exactly at one of the times you are simulating, and you won't have to be checking intervals or dealing with continuous time at all. At least, I think that's how it should work.

edit: I just realized all the shots would be fired perfectly at the simulation time intervals but I suppose they wouldn't necessarily land at the perfect times if you accounted for travel time, which IDK if you are trying to do that. But, at the end of the day, you are kind of trying to reconstruct the games combat engine, so it feels like emulating that will give best results.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 14, 2022, 10:40:20 PM
Well, the thinking is we want to use discrete time with larger intervals to reduce the number of computations we have to perform. But that got me thinking.

This method is going to make the original one using calculus and whatnot look like one of those jokes about mathematicians changing a lightbulb, but if we give up on the idea of constructing a cycle to iterate over, and instead just go to the time limit, then we can very easily compute all the timepoints at which shots from a weapon hit the enemy ship. Then we can define that, if H is the set of all such timepoints, s(t) = n({x in H | t-1 < x <= t}) with a special case for the first interval being [0,1] (which we avoid in the code below by adding a very small number to the time, though). We have direct access to n(set) in R using length(). So,

Code
#1. general constants
#the interval of discrete time (time lattice parameter) we are using in the model, in seconds
time_interval <- 1
#how long 1 tick of a beam lasts, in seconds
beam_tick_time <- 1/10
#maximum duration of combat in the model, in seconds
time_limit <- 500
#operating modes
UNLIMITED <- -1
GUN <- 0
BEAM <- 1

#times in seconds, ammoregen is in ammo / second
hits <- function(chargeup, chargedown, burstsize, burstdelay, ammo=UNLIMITED, ammoregen=0, reloadsize=0, traveltime=0, mode=GUN){
  #this vector will store all the hit time coordinates
  Hits <- vector(mode="double", length = 0)
  #current time
  #insert a very small fraction here to make time round correctly
  time <- 0
  #maximum ammo count is ammo given at start
  maxammo <- ammo
  #this is used to do ammo regeneration, 0 = not regenerating ammo, 1 = regenerating ammo
  regeneratingammo <- 0
  ammoregentimecoordinate <- 0
  ammoregenerated <- 0
 
  #we are firing a gun
  if (mode == GUN) {
    while(time < time_limit){
      time <- time + chargeup
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
     
      if (burstdelay == 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            ammo <- ammo - 1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
          }
        }
      }
      if (burstdelay > 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            time <- time + burstdelay
            ammo <- ammo -1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
           
          }
        }
      }
      time <- time+chargedown
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
    }
  }
 
  timeseries <- vector(mode="integer", length = time_limit/time_interval)
  timeseries[1] <- length(Hits[Hits >= 0 & Hits <= 1*time_interval])
  for (i in 2:time_limit/time_interval) timeseries[i] <- length(Hits[Hits > (i-1)*time_interval & Hits <= i*time_interval])
  return(timeseries)
}


#weird locust, from previous
> hits(0,5.44,56,0.1)
  [1] 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0  9
 [35] 10 10 10 10  7  0  0  0  0  0  9 10 10 10 10  7  0  0  0  0  0  8 10 10 10 10  8  0  0  0  0  0  8 10
 [69] 10 10 10  8  0  0  0  0  0  8 10 10 10 10  8  0  0  0  0  0  7 10 10 10 10  9  0  0  0  0  0  7 10 10
[103] 10 10  9  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0  6 10 10 10
[137] 10 10  0  0  0  0  0  5 10 10 10 10 10  1  0  0  0  0  5 10 10 10 10 10  1  0  0  0  0  4 10 10 10 10
[171] 10  2  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  3 10 10 10 10 10
[205]  3  0  0  0  0  3 10 10 10 10 10  3  0  0  0  0  2 10 10 10 10 10  4  0  0  0  0  2 10 10 10 10 10  4
[239]  0  0  0  0  2 10 10 10 10 10  4  0  0  0  0  1 10 10 10 10 10  5  0  0  0  0  1 10 10 10 10 10  5  0
[273]  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0  0  0  0 10 10 10 10 10  6  0  0
[307]  0  0  0  9 10 10 10 10  7  0  0  0  0  0  9 10 10 10 10  7  0  0  0  0  0  8 10 10 10 10  8  0  0  0
[341]  0  0  8 10 10 10 10  8  0  0  0  0  0  8 10 10 10 10  8  0  0  0  0  0  7 10 10 10 10  9  0  0  0  0
[375]  0  7 10 10 10 10  9  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0  6 10 10 10 10 10  0  0  0  0  0
[409]  6 10 10 10 10 10  0  0  0  0  0  5 10 10 10 10 10  1  0  0  0  0  5 10 10 10 10 10  1  0  0  0  0  4
[443] 10 10 10 10 10  2  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  4 10 10 10 10 10  2  0  0  0  0  3 10
[477] 10 10 10 10  3  0  0  0  0  3 10 10 10 10 10  3  0  0  0  0  2 10 10 10

That's what we know to be correct, so that's good. Let's try a more difficult one

> #ion pulser, with a travel time of 1 second
> hits(chargeup = 0.05,chargedown=0.05, burstsize = 3, burstdelay = 0.1, ammo = 20, ammoregen = 2, reloadsize = 3, traveltime = 1)
  [1] 0 8 7 8 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0
 [53] 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3
[105] 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3
[157] 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0
[209] 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3
[261] 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3
[313] 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0
[365] 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3
[417] 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3
[469] 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3 3 0 3


> #autopulse laser, with travel time 0.5 seconds
> hits(chargeup = 0.05,chargedown=0.05, burstsize = 1, burstdelay = 0, ammo = 30, ammoregen = 2, reloadsize = 0, traveltime = 0.5)
  [1]  5 10 10 10  3  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
 [35]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
 [69]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[103]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[137]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[171]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[205]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[239]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[273]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[307]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[341]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[375]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[409]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[443]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2
[477]  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2  2


To-do: beam weapons. I don't understand what this paragraph in the wiki means, mathematically speaking:
"chargedown
For non-beam weapons, this determines how long it takes for the weapon to be ready to fire again--i.e. cooldown. For beam weapons, this determines how long it takes for the beam to dissipate after the mouse button is released, where the beam is narrowing and doing less damage."

How much less damage? Any knowers?

Edit: added more checkpoints for ammo regeneration, there should be one whenever we add time.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on November 15, 2022, 02:11:46 AM
So I've amassed quite a bit of experimental results but not sure how to present it all just yet; not sure if it's better to put the results in this thread to keep all the mathematical findings about the Conquest together, or if it's better to put it in its separate thread since I'm looking more at "experimental results of effectiveness of different weapons using the Conquest as a platform and [REDACTED] as the enemy fleet" whereas the other main thrust of the thread is "developing a mathematical simulation of the effectiveness of different weapons" etc. Both are related to each other but take opposite approaches.

Anyway, wanted to respond to a couple of things quickly:

This is an exciting run-down. Please, if you can, include 4x Railgun with Ballistic Rangefinder for the Medium slots (same cost as 4x HVD, same range, better damage / flux) since Mjolnir / Locusts / Railguns is my Conquest layout and it's a stellar performer.

I can include the Railgun, but note that then you're looking at a symmetrical loadout rather than one side as the "main guns" and the other as the "defense" (i.e. Devastators). Generally speaking I've been looking at asymmetrical layouts, with one side having the bulk of the weapons. So not sure how much the other side would realistically contribute to battle.

Or I could loop through the collision boundary points in the .ship file to find the left-and right-most ones.

It should be easy enough to loop through the "bounds" section of each .ship file to determine when the weapon hits armor or hull. However, don't forget that shields are a simple circle so the shield width would be 2 times "shieldRadius" for calculating whether or not the weapon hits shields.

Of course in terms of the real game we might rather set SD=ship top speed. Or might consider this: give each weapon and ship its own SD, given by projectile travel time * enemy top speed / 2. Then we're essentially saying that 95% of the time, the enemy's position relative to the original location has changed at most by its maximum travel under its own propulsion during that time (why it might "change more" is differences in our own position and facing compared to the ideal).

The weapon does do a certain amount of target leading however. I haven't really tested autofire accuracy directly, but I would think that the SD might actually be based more on the target's acceleration than the target's top speed, since the target leading would already account for the target's speed to a certain extent. I virtually always run my fleet at 100% CR which I think means that autofire is always 100% accurate (meaning, it fully takes the target's speed into account). Accounting for maneuverability might be tricky though, because it depends on whether or not the AI decides to maneuver out of the path of an incoming projectile, and I have no idea how that's handled.

Doing this with integers over discrete time is going to be exactly the strength of this program, as it's very difficult analytically (how do you calculate the armor damage reduction?) and the computing time would be soul crushingly long using something like increments of 0.01s for what this program is supposed to do

I don't know how often the game actually computes the game state. However, settings.json has "minRefireDelay" of 0.05 seconds meaning a weapon can only fire up to 20 times per second. (It can fire multiple projectiles shotgun-style each time though.) If I remember right, beam damage is applied 10 times a second (...or at least, the damage displayed is updated 10 times a second). So yeah, realistically, the smallest time increment you would need to worry about is 0.05 seconds.

Hm, what if we somehow went shot-by-shot instead of increment-by-increment of time?

Seems like then you're looking at a Discrete Event Simulation, and it looks like there is readily-available Python code for that and the concept is pretty easy to look up. Basically the code would be looking at how long before each weapon fires, select the one with the shortest cooldown remaining, advance time by that amount (decreases all weapons' cooldown by that amount), fire that weapon (processes whether or not it hits the target, etc.), adds that weapon's refire delay to its cooldown remaining, then repeat. That way the code wouldn't be calculating over a bunch of time increments where nothing happens. Should be easy to process ammo and ammo regen by the same logic as well.

To-do: beam weapons. I don't understand what this paragraph in the wiki means, mathematically speaking:
"chargedown
For non-beam weapons, this determines how long it takes for the weapon to be ready to fire again--i.e. cooldown. For beam weapons, this determines how long it takes for the beam to dissipate after the mouse button is released, where the beam is narrowing and doing less damage."

How much less damage? Any knowers?

The damage for both the chargeup and chargedown for beams are quadratic, i.e. starting at 0, then going up to the "damage/second" value at the end of the chargeup, and the opposite for the chargedown. Conceptually this is easy in that the total damage done during that time is 1/3 of the damage/second times the chargeup or chargedown, which makes it easy to calculate the total damage per burst, for example (the total damage done during chargeup and chargedown is damage/second * (chargeup + chargedown) / 3). The beam's hit strength is unaffected by this; it's always half of the full-strength DPS (the "dpsToHitStrengthMult" value in settings.json), regardless of how much damage it's doing per tick. So for example, a Tachyon Lance does 1500 damage/second while it's active, so its hit strength is always 750 even when it's charging up or charging down.

Some experimental data of how much damage a Tachyon Lance did to shields is below. Note that Starsector displays cumulative damage, whereas this is the damage per display tick from that cumulative damage displayed:

Spoiler
Code
Test 1
18
46
87
136
152
152
152
152
152
152
152
151
152
149
125
99
76
55
39
24
14
6
3

Test 2
1
13
35
71
120
152
152
152
152
152
152
152
152
152
152
134
108
83
61
44
29
17
8
6

Test 3
5
24
53
96
144
152
152
152
152
152
152
152
152
152
144
116
90
68
50
33
21
11
4
1
[close]

You can see that in all cases, the damage gradually ramps up quadratically to the max, then ramps down quadratically afterward.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 15, 2022, 05:13:15 AM
All right, well then just need to add a switch to always use full hit strength for beam weapons to the damage code, but I think we want to return beam ticks multiplied by beam intensity here and actually return beam ticks as a real number rather than an integer. The damage code should later convert this to 1 hit with hit strength of full beam and damage of the fraction presented here. I also added a sanity check so even if a modder has specified lower values it will use the game's global minimum cooldown.

Code
#1. general constants
#the interval of discrete time (time lattice parameter) we are using in the model, in seconds
time_interval <- 1
#how long 1 tick of a beam lasts, in seconds
beam_tick <- 1/10
#minimum interval that exists in the game, in case a modder has somehow specified a lower value for something
global_minimum_time <- 0.05
#maximum duration of combat in the model, in seconds
time_limit <- 500
#operating modes
UNLIMITED <- -1
GUN <- 0
BEAM <- 1

#times in seconds, ammoregen is in ammo / second
hits <- function(chargeup, chargedown, burstsize, burstdelay, ammo=UNLIMITED, ammoregen=0, reloadsize=0, traveltime=0, mode=GUN){
  #specify sane minimum delays, since the game enforces weapons can only fire once every 0.05 sec
  #for beams, refiring delay is given by burstdelay, for guns it is burstdelay in case burstdelay is > 0 (==0 is shotgun) and chargedown
  if(burstdelay > 0 | mode == BEAM) burstdelay <- max(burstdelay, global_minimum_time)
  if(mode == GUN) chargedown <- max(chargedown, global_minimum_time)
  #this vector will store all the hit time coordinates
  #current time
  #insert a very small fraction here to make time round correctly
  time <- 0.001
  #maximum ammo count is ammo given at start
  maxammo <- ammo
  #this is used to do ammo regeneration, 0 = not regenerating ammo, 1 = regenerating ammo
  regeneratingammo <- 0
  ammoregentimecoordinate <- 0
  ammoregenerated <- 0
 
  #we are firing a gun
  if (mode == GUN) {
    Hits <- vector(mode="double", length = 0)
    while(time < time_limit){
      time <- time + chargeup
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
     
      if (burstdelay == 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            ammo <- ammo - 1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
          }
        }
      }
      if (burstdelay > 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            time <- time + burstdelay
            ammo <- ammo -1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
           
          }
        }
      }
      time <- time+chargedown
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
    }
    timeseries <- vector(mode="integer", length = time_limit/time_interval)
    timeseries[1] <- length(Hits[Hits >= 0 & Hits <= 1*time_interval])
    for (i in 2:time_limit/time_interval) timeseries[i] <- length(Hits[Hits > (i-1)*time_interval & Hits <= i*time_interval])
    return(timeseries)
  }
  #we are firing a beam
  if (mode == BEAM) {
    chargeup_ticks <- chargeup/beam_tick
    chargedown_ticks <- chargedown/beam_tick
    burst_ticks <- burstsize/beam_tick
    #for a beam we will instead use a matrix to store timepoint and beam intensity at timepoint
    beam_matrix <- matrix(nrow=0,ncol=2)
    #burst size 0 <- the beam never stops firing
    if(burstsize == 0){
      for (i in 1:chargeup_ticks) {
        #beam intensity scales quadratically during chargeup, so
      }
      while ( time < time_limit) {
        beam_matrix <- rbind(beam_matrix,c(time, 1))
        time <- time+beam_tick
      }
    } else {
      while (time < time_limit) {
        if (ammo != 0){
          ammo <- ammo - 1
          if (chargeup_ticks > 0){
          for (i in 1:chargeup_ticks) {
            beam_matrix <- rbind(beam_matrix,c(time, (i*beam_tick)^2))
            time <- time+beam_tick
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
          }
          for (i in 1:burst_ticks){
            beam_matrix <- rbind(beam_matrix,c(time, 1))
            time <- time+beam_tick
            if(time - ammoregentimecoordinate > 1/ammoregen){
               ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
               ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
               if(ammoregenerated >= reloadsize){
                 ammo <- ammo+ ammoregenerated
                 ammoregenerated <- 0
               }
               if(ammo >= maxammo){
                 ammo <- maxammo
                 regeneratingammo <- 0
               }
            }
          }
         
          if (chargedown_ticks > 0){
          for (i in 1:chargedown_ticks){
            beam_matrix <- rbind(beam_matrix,c(time, ((chargedown_ticks-i)*beam_tick)^2))
            time <- time+beam_tick
          }
          if(time - ammoregentimecoordinate > 1/ammoregen){
            ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
            ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
            if(ammoregenerated >= reloadsize){
              ammo <- ammo+ ammoregenerated
              ammoregenerated <- 0
            }
            if(ammo >= maxammo){
              ammo <- maxammo
              regeneratingammo <- 0
            }
          }
          }
          time <- time + burstdelay
          if(time - ammoregentimecoordinate > 1/ammoregen){
            ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
            ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
            if(ammoregenerated >= reloadsize){
              ammo <- ammo+ ammoregenerated
              ammoregenerated <- 0
            }
            if(ammo >= maxammo){
              ammo <- maxammo
              regeneratingammo <- 0
            }
          }
        }
        time <- time + global_minimum_time
        if(time - ammoregentimecoordinate > 1/ammoregen){
          ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
          ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
          if(ammoregenerated >= reloadsize){
            ammo <- ammo+ ammoregenerated
            ammoregenerated <- 0
          }
          if(ammo >= maxammo){
            ammo <- maxammo
            regeneratingammo <- 0
          }
        }
      }
    }
    timeseries <- vector(mode="double", length = time_limit/time_interval)
    for (i in 1:length(timeseries)) {
      timeseries[i] <- sum(beam_matrix[beam_matrix[,1] < i & beam_matrix[,1] > i-1,2])
    }
    return(timeseries)
  }
}


#tachyon lance
> hits(chargeup = 0.5,chargedown=1, burstsize = 1, burstdelay = 4, ammo = UNLIMITED, ammoregen = 0, reloadsize = 0, traveltime = 0, mode=BEAM)
  [1]  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00
 [18]  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00
 [35]  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00
 [52]  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30
 [69]  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55
 [86] 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00
[103]  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55
[120]  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00
[137]  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00
[154]  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55
[171]  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00
[188]  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85
[205]  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00
[222]  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00
[239]  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00
[256]  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00
[273]  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30
[290]  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55
[307] 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00
[324]  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55
[341]  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00
[358]  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00
[375]  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55
[392]  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00
[409]  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85
[426]  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00
[443]  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00
[460]  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00
[477]  2.85  0.00  0.00  0.00  0.00  5.55  7.55  0.30  0.00  0.00  0.00  0.55 10.00  2.85  0.00  0.00  0.00
[494]  0.00  5.55  7.55  0.30  0.00  0.00  0.0



> #Paladin PD system
> hits(chargeup = 0,chargedown=0, burstsize = 0.2, burstdelay = 0.1, ammo = 20, ammoregen = 1, reloadsize = 0, traveltime = 0, mode=BEAM)
  [1] 6 6 6 6 5 6 5 6 6 6 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
 [53] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[105] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[157] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[209] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[261] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[313] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[365] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[417] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
[469] 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2


If anybody wants to do the shot by shot instead, to skip empty cycles completely, then that sounds pretty smart. Although real time savings may be limited because some weapon is probably going to be firing much of the time. If we keep going with this code, we should definitely include a switch where it checks if any weapons are firing at that timepoint before doing anything (other than dissipate flux) in the damage calculation to skip empty cycles. Possibly could also look ahead to find the next timepoint at which some of the vectors are not empty, and jump there, if that is actually faster (I'm not sure, not a programmer, if it still has to check all the elements then it might just be the same thing).

Edit: fixed hangup when no ammo, added minimum progress of time in that case. Edit 2: the global minimum should only apply to burst delay and chargedown, and for guns, only if burst delay is > 0 since burst delay=0 means shotgun style, and only to chargedown of guns, per what Vanshilar said, so changed that, which also slightly changed the results for the Paladin so reposted.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on November 15, 2022, 09:09:46 AM
Oh yeah for the Paladin, even though its "damage/second" is 1000, its .wpn file (which in this case is guardian.wpn) has 5 turrets defined, so that tells the game to split it up into 5 beams of 200 DPS. Thus in this case, it's a shotgun of 5 beams, each at 100 hit strength.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 15, 2022, 10:13:34 PM
Alright next part of the research program: how does gun turn rate affect accuracy?

Suggested program:
1. Ignore acceleration and facing
2. Enemy ship moves in Brownian motion such that x_1=x_0+s_x and y_1=y_0+s_y, where s_x is 1 random value drawn from normal distribution N(0, (top speed/2)^2)-cos(angle of enemy ship after movement)*our top speed
3. Our previous turret facing was 0 for first iteration / saved value for previous, and our new turret facing is alpha=max(0,arcsin(delta x of enemy ship movement/range)-previousangle-turret rotation speed per second), with appropriate signs figured out and used
4. Simulate model 1 000 times, plot a generalized linear model for enemy ship speed and turret rotation rate to predict hitrate/hitratewithnomovement

Gonna be a moment before can do this, so figured would ask anybody see problems?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 16, 2022, 12:17:03 AM
I think the only way turret turn rate would directly result in shots being off target is if the turret can't turn fast enough to track the target.

I think movement based inaccuracy is actually a question of the target leading performance and the turret turn rate is more of a constraint on target leading performance, rather than a source of random inaccuracy.

The question of if the turn rate is fast enough to track the target moving at top speed is deterministic based on relative motion, turn rate, and range. You could also consider your own ship turn rate as well. I think it should be possible to identify some practical conditions under which the weapon is incapable of tracking the target (stuff like x weapon cannot track y ship moving at top speed at z range or something).

The question of how much acceleration of the target displaces the impact point from the aim point (assuming perfect tracking), is another matter. The idea here is that the aim-leading is (I think, someone correct me if I am wrong) just using the targets velocity at the moment of firing to determine where it needs to aim for the shot to land center-mass (well, really a shot aligned with the center of the aim cone), but acceleration will result in the ship being in a different location, and so there should be a relationship between the acceleration and how off-target the shot is. I'm also not sure if the motion of your own ship is accounted for in the target leading. I'm pretty sure acceleration in the game is constant and binary (on or off in each direction, unless it ramps up, but I don't think that's the case), so it should be pretty tractable analytically.

Also, you've got some dimension issues in your equations. In particular, angles added with angular rates. Probably need to multiply angular rates by a time to get an angle.

Also, for what it's worth, using brownian motion is not ignoring acceleration, it's using random acceleration at each time step, which I don't think is a particularly realistic model.

Hopefully I will have time to write up some equations for what I am thinking tomorrow.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 16, 2022, 12:56:17 AM
Let's hear those equations. You can just assume time interval = 1 second because that's the thing we're going for.

Anyway, if we have that aim leading target = f(enemy velocity, enemy acceleration, enemy location), and f(0,0,0) = 0, and f is linear, and E(enemy velocity)=0, E(enemy acceleration)=0, E(enemy location)=0, then isn't E(f(...)) =0?

What we expect to find is that for some enemy top speed, and for some combination of enemy top speed and turret turn rate, there is a proportion of enemies that will escape tracking during simulation, although this proportion would be 0 due to enemy movement being random and our ship's not being so, if the simulation were to go on infinitely. Also, below a certain enemy top speed, our ship is able to always catch up to them and keep them at a hair's breadth's distance, making any tracking irrelevant.

Because of these weird limits that don't have much to do with the real combat situation, it could also make sense to fix the enemy's range and only consider motion along a circle of radius range around our ship. But non-random motion would be extremely weird then, because that would just make all ships capable of doing so escape from the gun's firing arc asap and stay away, and not affect ships that can't escape at all. This problem would be worse with non-random free motion though, since then all ships that can do so would escape in two dimensions. Unless the AI can be distilled into some equation of motion that doesn't escape, and is realistic, and isn't random?

Essentially it seems like we want random motion as that matches the experience in the game more than the enemy ship just maneuvering away if it can escape or getting caught forever if it can't, unless there is something better to use. Brownian motion kind of fits because you might imagine the enemy ship being bounced around by random forces - enemy ships, enemy fire, and the AI's own rapidly changing tendencies - but more realistic would probably be to also have a tendency to a particular range. However any complexity is also always open to criticism about realism so this should be based on some kind of understanding of the AI and explicable assumptions. And random motion with tendency will just result in ships that can staying at that range and others falling towards our ship like rocks from the pursuit motion.  Puzzle what to do. Possibly tendency for our ship to keep distance... But won't it just degenerate into the circle model? With two orbits for two preferred distances. Leading to a weird threshold in ship top speed changing range discontinuously at some point. Just thinking out loud here.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 16, 2022, 05:19:16 PM
Ships escaping/evading is exactly what happens in combat though? The typical engagement cycle for the AI, is approach (into weapons range) and try to flank until someones flux is too high, then that ship tries to retreat to vent. Small fast ships flanking at relatively close range will definitely completely avoid slow turret shots. The only part that could look like random movement is when small fast ships are evading shots, but they are still reacting to your shots, so it's not really random.

Also, like I said before, target leading is the real source of inaccuracy, while turn rate is just a constraint on what target leading you can achieve. That's because you have to predict where the target will go to hit them with a non-hitscan projectile, and your prediction can't account for what happens between firing and impact. A simple model target leading would just assume constant velocity. Something like this:
https://gamedev.net/forums/topic/457840-calculating-target-lead/457840/

I had some ideas about trying to simulate evasion (basically assuming a worst-case of the enemy accelerating in the opposite direction as soon as you fire and seeing how far off target the shot is), but after messing around for a bit, I'm not even sure if that is worth doing. It feels too situation-specific to create any meaningful generalizations, but I guess that's how pretty much all of this movement stuff feels. You might be able to get some results about the relationship between a ships maneuverability and what projectile velocities it can reliably evade, which could be interesting.

I guess you can do whatever you want, but I feel like there are much more valuable ways to improve the simulation besides worrying about this. I think that modeling movement and target-leading wrong could make the simulation considerably less realistic too.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 17, 2022, 03:21:46 AM
Well, let's just try the random movement thing and see how it goes. The nice thing about random movement is since enemy acceleration and movement is random with an expected value of 0, it is all the same whether you are leading the target or not, making things simpler.

Spoiler
Code
x <- 0
y <- 1000
alpha <- 0
xour <- 0
your <- 0
beta <- pi/2
enemytopspeed <- 300
ourtopspeed <- 50
turretrotationrate <- 30*pi/180
mindistance <- 50

locationmatrix <- data.frame()

for (t in 1:100) {
  locationmatrix <- rbind(locationmatrix, c(t,x,y,xour,your,gamma))
  #the enemy is not allowed to come within mindistance pixels of our ship in either direction
  deltax <- rnorm(1,0,enemytopspeed)
  deltay <- rnorm(1,0,ourtopspeed)
  while(abs(x+deltax-xour) < mindistance) deltax <- deltax <- rnorm(1,0,enemytopspeed)
  while(abs(y+deltay-xour) < mindistance) deltay <- rnorm(1,0,ourtopspeed)
  #alpha is the angle of directional vector from our ship to enemy
 
 

  x <- x+deltax
  y <- y+deltay
  alpha <- atan2(y-your,x-xour)
 
  xour=xour+cos(alpha)*ourtopspeed
  your=your+sin(alpha)*ourtopspeed 
 
 
  #beta is the angle of our turret
  #rotate beta towards alpha by a maximum of rate turretrotationrate
  beta <- beta + sign(alpha-beta)*min(turretrotationrate,abs(alpha-beta))
 
  #gamma is the angle from our turret facing to the enemy ship
  gamma <- alpha-beta
  print(gamma)
}
names(locationmatrix) <- c("t","x","y","xour","your","gamma")
library(ggplot2)

ggplot(data=locationmatrix)+
  geom_path(aes(x=x,y=y,col="ENEMY"))+
  geom_point(aes(x=x,y=y,col="ENEMY"))+
  geom_path(aes(x=xour,y=your))+
  geom_point(aes(x=xour,y=your))

ggplot(data=locationmatrix)+
  geom_path(aes(x=gamma*180/pi,y=t))+
  geom_point(aes(x=gamma*180/pi,y=t))
[close]
Here are some random paths (enemy speed 300, our speed 50)
(https://i.ibb.co/YX3Lvt3/image.png) (https://ibb.co/T1kgzHk)

I have yet to see it escape once. Seems like the randomness is simply too much.

Here is a plot with also the enemy's apparent position in degrees from the point of view of our turret which is trying to rotate in pursuit (max turret rotation rate 30 deg / sec, assume infinite turret arc, assume ship does not rotate)

(https://i.ibb.co/QbWFxMz/image.png) (https://ibb.co/smTvXqk)
Now it seems like it's actually a pretty bad idea for our ship to close in to point blank range because then the apparent angle changes faster than the turret can follow. So let's make it keep a minimum distance of 1000.

Code
  if(sqrt((x-xour)^2+(y-your)^2)>1000){
  xour=xour+cos(alpha)*ourtopspeed
  your=your+sin(alpha)*ourtopspeed
  }

(https://i.ibb.co/tm4Sd16/image.png) (https://ibb.co/mDGmxPK)

Most of the time we are going to be hitting this speed 300 ship with no problem. What about with a turret rotation speed of 3 (Gauss cannon) rather than 30?

(https://i.ibb.co/Ptc89ZF/image.png) (https://ibb.co/g6SCPMm)

This certainly seems like it would affect damage output a little.

(Edit: change starting turret angle to pi/2 from -pi/2, was latter in graphs)


Con't

Now that we have a model, we can also integrate probability to hit into it. Let's say that the enemy ship is a 100 px wide sphere. Then previously in this thread we found probability to hit a coordinate less than Z for a weapon to be


G <- function(y) return(y*pnorm(y) + dnorm(y))
fEz <- function(z, a, b) return((1/2/b)*(pnorm(z/a+b/a)-pnorm(z/a-b/a)))
PrEltZ <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
(note: if b=0, use normal distribution instead)


where b is the weapon spread and a is the SD of the normal distribution (presumably 50 px). Now if coordinate 0 is right in front of us then probability to hit enemy ship is PrEltZ(enemyshipleftbound)-PrEltZ(enemyshiprightbound). The enemy ship's left bound is approximately given by arc length to enemy ship middle -50 px, so (gamma*r). So we have the probability to hit is approx PrEltZ(range*gamma+50, SD, spread)-PrEltZ(range*gamma-50, SD, spread).

Putting it all together we get this code
Spoiler
Code
x <- 0
y <- 1000
alpha <- 0
xour <- 0
your <- 0
beta <- pi/2
enemytopspeed <- 300
ourtopspeed <- 50
turretrotationrate <- 3*pi/180
mindistance <- 50
SD <- 50
spread <- 0

G <- function(y) return(y*pnorm(y) + dnorm(y))
fEz <- function(z, a, b) return((1/2/b)*(pnorm(z/a+b/a)-pnorm(z/a-b/a)))
PrEltZ <- function(z, a, b){
  if(b==0) return(pnorm(z,0,a)) else return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
}

locationmatrix <- data.frame()

for (t in 1:100) {
  locationmatrix <- rbind(locationmatrix, c(t,x,y,xour,your,prob))
  #the enemy is not allowed to come within mindistance pixels of our ship in either direction
  deltax <- rnorm(1,0,enemytopspeed)
  deltay <- rnorm(1,0,ourtopspeed)
  while(abs(x+deltax-xour) < mindistance) deltax <- deltax <- rnorm(1,0,enemytopspeed)
  while(abs(y+deltay-xour) < mindistance) deltay <- rnorm(1,0,ourtopspeed)
  #alpha is the angle of directional vector from our ship to enemy
 
 

  x <- x+deltax
  y <- y+deltay
  alpha <- atan2(y-your,x-xour)
 
  range <-sqrt((x-xour)^2+(y-your)^2)
 
  if(range>1000){
  xour=xour+cos(alpha)*ourtopspeed
  your=your+sin(alpha)*ourtopspeed
  }
 
 
 
  #beta is the angle of our turret
  #rotate beta towards alpha by a maximum of rate turretrotationrate
  beta <- beta + sign(alpha-beta)*min(turretrotationrate,abs(alpha-beta))
 
  #gamma is the angle from our turret facing to the enemy ship
  gamma <- alpha-beta
  prob <- PrEltZ(gamma*range+50, SD, spread)-PrEltZ(gamma*range-50,SD,spread)
  print(prob)
}
names(locationmatrix) <- c("t","x","y","xour","your","hitprobability")
library(ggplot2)

ggplot(data=locationmatrix)+
  geom_path(aes(x=x,y=y,col="ENEMY"))+
  geom_point(aes(x=x,y=y,col="ENEMY"))+
  geom_path(aes(x=xour,y=your))+
  geom_point(aes(x=xour,y=your))

#ggplot(data=locationmatrix)+
#  geom_path(aes(x=gamma*180/pi,y=t))+
#  geom_point(aes(x=gamma*180/pi,y=t))

ggplot(data=locationmatrix)+
    geom_path(aes(y=hitprobability,x=t))
 
[close]

And these somewhat unflattering graphs for the Gauss (spread 0, turn rate 3)

(https://i.ibb.co/xqLXc28/image.png) (https://ibb.co/HT4BMKp)

Using these assumptions, what kinds of hit rates will we get for various turret rotation speeds? Let's stick with the Gauss (spread 0) for now, but try turn rate 0, 3, 5, 10, 15, 20 and 30. Run 1000 combats and take the average.

Turn rate  Hit rate
0          0.4%
3          23.4%
5          32.4%
10         42.5%
15         49.1%
20         52.9%
30         57.5%


That is versus a small speed 300 ship so it's no wonder turn rate is important. But it also suggests that this is important enough that it should be included in any comprehensive model of space combat (and also suggests that you should put advanced turret gyros on your Gauss Conquests, incidentally). There also appears to be such a thing as "enough turn rate" with diminishing returns as the turret is mostly pointed in the right direction.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 17, 2022, 09:23:21 AM
Just to be clear, I wasn't saying that there is not significant affect on accuracy due to movement, but rather that having an inaccurate model won't generate meaningful results. I think a broad conclusion like "you should put turret gyros on a ship with gauss" is fine (although you could have come up with that without these simulations), but doing something like taking these hit%'s and using them in a simulation to account for inaccuracy due to movement I think is a bad idea.

A simple approximation of required turret speed to track a target is w > v/r (based on the definition of angular velocity, it's more complicated when considering shot velocity) where w is the angular rate, v is the perpendicular portion of the relative velocity, and r is the range. It feels like this entire analysis can be boiled down to saying that sometimes, at certain ranges and relative motions, a ship with 300 speed can exceed the conditions that a turret with a certain turn rate can track (which is basically the analysis I was talking about earlier), and that happens more often when the turret turn rate is slower. How often does that happen in real combat? I don't think this simulation really reflects the answer to that, all it shows is how often that happens for this specific type of random motion.

The AI and the player are both actively managing range and relative velocity, so having random movement really alters the results in that sense compared to real combat IMO. I think you have to actually reproduce the AI behavior to try an answer the question of how often shots will miss due to movement in practice, which just doesn't seem like a worthwhile pursuit.

Also, 300 speed is super fast (like the fastest frigate with SO fast, no ship in the game has a base speed over 200). I'm pretty sure in the game, the hit rate would be very close to zero for a gauss against something that fast, except for maybe one or two lucky shots. In fact, most projectile weapons would be incapable of reliably hitting a 300 speed ship at longer ranges due to shot travel time/velocity. There's actually a trade off where longer range means more time between firing and impact for the target to dodge reactively, but shorter range means harder to track. Usually, I think the shot speed makes more of a difference though in terms of accuracy in-practice.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 17, 2022, 09:55:58 AM
Open to ideas about how to take it further, show me the math!

I don't personally think the random motion is that bad. Consider that we're going to be taking the average of 100 000 movements per enemy ship per weapon if using just this code. While it's not the same as the AI, if you just consider that it's a bunch of numbers reflecting current turret position, enemy ship movement and turret's ability to follow, it may not be that bad (technically since each enemy movement is random and independent you could consider that they happen in any order, including more reasonable sequences). Not claiming that one single run of this model is saying much. But the aggregate may? At the very least may be better than nothing. And it neatly solves the AI simulation problem as otherwise we'd need to model not only AI but also target leading.

The idea here would be to do that, ie take the average result of running this 1000 times, over a variety of ship sizes and parameters of weapon (obviously not a crazily jinking speed 300 frigate used for the demo) and then try to fit some kind of equation that we could use in the main model. As this does seem to be a thing that should be represented somehow. Probably come with a switch to turn it off so people can see results without if they're not comfortable with the assumptions.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 17, 2022, 10:12:05 AM
At the very least may be better than nothing.

It's possible to be worse than nothing too. If it makes guns seems worse or better than they actually are and you reach misleading conclusions.

What results do you get if you use a reasonable top speed like 100 or 150 or something?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 17, 2022, 10:20:44 AM
True. Hence should have an off switch so if it seems untrue to player experience or, say, player feels comfortable compensating for poor turn rate, can just see results vs immobile objects. Stay tuned for results vs more ordinary opponents, currently away from computer but will likely have more time for this tomorrow.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 17, 2022, 12:27:16 PM
I fear simulating ship maneuvers would be too difficult and would rather have a fast, simple model of face-to-face firepower.  A target either can or cannot flank faster than enemy turrets can turn but could only rarely dodge a projectile by moving slightly.  Few ships are so maneuverable as to consistently get behind any opponents, which themselves usually have rear-facing or turreted weapons to fight them.  Some ships also have movement abilities, etc., which can decide this question outright.  Answering this question would ultimately entail re-implementing the Starsector combat engine and AI.

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 18, 2022, 04:40:20 AM
I don't really think it's as simple as that. The AI almost certainly does not dodge optimally but is under a variety of influences leading to semi-random behavior. I currently run a mixed fleet of Conquests and Brawlers and the Brawlers routinely catch Reapers and other missiles and projectiles like demented pong-bats instead of just doing the reasonable thing and dodging or even just staying course which would have lead to the projectile missing.

Anyway, the reason why the random model would work is not that it accurately simulates the AI, but rather that if it includes roughly every possible movement that is likely to occur in the real game then the results on average would reflect turret tracking ability. Now if we assume there is a huge number of inputs to the AI, then if we don't know anything about what is going on in combat, we may assume the AI's choice of movement is normally distributed. Then we are essentially doing a Monte Carlo simulation of what is happening in combat to get these numbers. An alternative way to look at it is that since the enemy ship movement is independent on any given time interval, then you could just re-arrange the sequence into what is a "reasonable" combat sequence (which there are probably many of if we have 100k sequences). Now if you were to take the average of the result you would end up with the same average as you get from the random sequence.

Like said I am open to improvements here though, but I still feel it would be nice to have a(n optional) parameter to describe turret tracking ability too.

Anyway, lower our speed to 45 (Conquest, no zero flux boost or Maneuvering jets), and add the Conquest's own turn rate of 6. Then let's make the target ship the size of a Dominator (220 px). I'll also lower the number of simulations we are doing because this is one program that again takes a very long time to run. Here are the hit rates (100 simulations of 100 seconds for each combination of turn rate and top speed) per turn rate for turn rate from 0 to 30, in increments of 5, and enemy top speed from 0 to 200, in increments of 10, still using Gauss (spread 0).
Code
x <- 0
y <- 1000
alpha <- 0
xour <- 0
your <- 0
beta <- pi/2
enemytopspeed <- 300
ourtopspeed <- 45
turretrotationrate <- 5*pi/180
mindistance <- 50
SD <- 50
spread <- 0
targetwidth <- 220


G <- function(y) return(y*pnorm(y) + dnorm(y))
fEz <- function(z, a, b) return((1/2/b)*(pnorm(z/a+b/a)-pnorm(z/a-b/a)))
PrEltZ <- function(z, a, b){
  if(b==0) return(pnorm(z,0,a)) else return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
}

averages <- data.frame()
for (s in seq(10,200,10)) {
  enemytopspeed <- s
  print(c("enemy top speed is:", s))

 
for (r in seq(0,30,5)) {
  turretrotationrate <- (r+6)*pi/180
  print(c("turret rotation rate is:", r))
 

for (i in 1:100) {
  locationmatrix <- data.frame()
  x <- 0
  y <- 1000
for (t in 1:100) {
 
  #the enemy is not allowed to come within mindistance pixels of our ship in either direction
  deltax <- rnorm(1,0,enemytopspeed)
  deltay <- rnorm(1,0,ourtopspeed)
  while(abs(sqrt((x+deltax-xour)^2+(y+deltay-your)^2)) < mindistance){
     deltax <- rnorm(1,0,enemytopspeed)
     deltay <- rnorm(1,0,enemytopspeed)

  }


 
  #alpha is the angle of directional vector from our ship to enemy
 
 

  x <- x+deltax
  y <- y+deltay
  alpha <- atan2(y-your,x-xour)
 
  range <-sqrt((x-xour)^2+(y-your)^2)
 
  if(range>1000){
  xour=xour+cos(alpha)*ourtopspeed
  your=your+sin(alpha)*ourtopspeed
  }
 
 
  #beta is the angle of our turret
  #rotate beta towards alpha by a maximum of rate turretrotationrate
  beta <- beta + sign(alpha-beta)*min(turretrotationrate,abs(alpha-beta))
 
  #gamma is the angle from our turret facing to the enemy ship
  gamma <- alpha-beta
  prob <- PrEltZ(gamma*range+targetwidth/2, SD, spread)-PrEltZ(gamma*range-targetwidth/2,SD,spread)
  locationmatrix <- rbind(locationmatrix, c(t,x,y,xour,your,prob))
 
}
  names(locationmatrix) <- c("t","x","y","xour","your","hitprobability")

averages <- rbind(averages, c(mean(locationmatrix$hitprobability),s,r,i))
}
}
}
names(averages) <- c("hitprob","topspeed","turnrate","run")
averages
 

After aggregating the runs and taking the mean of hit probability across runs, we get this data
Spoiler

    topspeed turnrate   hitprob
1         10        0 0.8638403
2         20        0 0.7576896
3         30        0 0.7402694
4         40        0 0.6625848
5         50        0 0.7369327
6         60        0 0.6875313
7         70        0 0.6765392
8         80        0 0.6943154
9         90        0 0.6013359
10       100        0 0.5908593
11       110        0 0.5747073
12       120        0 0.5633436
13       130        0 0.5306272
14       140        0 0.5736074
15       150        0 0.5375646
16       160        0 0.5141144
17       170        0 0.4734542
18       180        0 0.4806974
19       190        0 0.4824674
20       200        0 0.4658784
21        10        5 0.9087731
22        20        5 0.8160650
23        30        5 0.8007856
24        40        5 0.8739375
25        50        5 0.8426129
26        60        5 0.7777419
27        70        5 0.7297531
28        80        5 0.7682660
29        90        5 0.7346132
30       100        5 0.7374037
31       110        5 0.7132191
32       120        5 0.7414817
33       130        5 0.7180191
34       140        5 0.6720560
35       150        5 0.6864451
36       160        5 0.6563502
37       170        5 0.6403787
38       180        5 0.6585675
39       190        5 0.6522295
40       200        5 0.6583766
41        10       10 0.9161702
42        20       10 0.8768744
43        30       10 0.8711839
44        40       10 0.8163568
45        50       10 0.8280426
46        60       10 0.8687802
47        70       10 0.8442793
48        80       10 0.8457214
49        90       10 0.8244319
50       100       10 0.8005428
51       110       10 0.7891478
52       120       10 0.7811752
53       130       10 0.7514515
54       140       10 0.7387135
55       150       10 0.7595159
56       160       10 0.7791199
57       170       10 0.7592434
58       180       10 0.7451520
59       190       10 0.7166897
60       200       10 0.7122592
61        10       15 0.9395805
62        20       15 0.9149204
63        30       15 0.8906036
64        40       15 0.8812045
65        50       15 0.8837285
66        60       15 0.8625697
67        70       15 0.8267144
68        80       15 0.8592025
69        90       15 0.8509674
70       100       15 0.8443408
71       110       15 0.8358114
72       120       15 0.8115829
73       130       15 0.8178224
74       140       15 0.8106944
75       150       15 0.8310698
76       160       15 0.7611141
77       170       15 0.7699804
78       180       15 0.7759956
79       190       15 0.7673662
80       200       15 0.7555627
81        10       20 0.9478558
82        20       20 0.9373705
83        30       20 0.9119520
84        40       20 0.8936612
85        50       20 0.8800513
86        60       20 0.8950729
87        70       20 0.8832973
88        80       20 0.8679177
89        90       20 0.8659575
90       100       20 0.8623654
91       110       20 0.8525335
92       120       20 0.8455205
93       130       20 0.8270614
94       140       20 0.8285563
95       150       20 0.8366158
96       160       20 0.8015315
97       170       20 0.8208051
98       180       20 0.8127251
99       190       20 0.7871574
100      200       20 0.8111585
101       10       25 0.9517321
102       20       25 0.9104478
103       30       25 0.8507284
104       40       25 0.9002465
105       50       25 0.8612552
106       60       25 0.8786419
107       70       25 0.8766353
108       80       25 0.8795814
109       90       25 0.8523118
110      100       25 0.8774695
111      110       25 0.8700940
112      120       25 0.8635090
113      130       25 0.8629561
114      140       25 0.8372481
115      150       25 0.8351283
116      160       25 0.8365642
117      170       25 0.8326198
118      180       25 0.8342032
119      190       25 0.8114680
120      200       25 0.8155941
121       10       30 0.9366022
122       20       30 0.9257401
123       30       30 0.8604539
124       40       30 0.9366947
125       50       30 0.8971899
126       60       30 0.8892408
127       70       30 0.8747965
128       80       30 0.8934654
129       90       30 0.8958007
130      100       30 0.8886172
131      110       30 0.8779249
132      120       30 0.8868961
133      130       30 0.8641212
134      140       30 0.8705663
135      150       30 0.8562662
136      160       30 0.8629481
137      170       30 0.8520315
138      180       30 0.8384351
139      190       30 0.8556037
140      200       30 0.8438437
[close]

(https://i.ibb.co/X3Z4GkW/image.png) (https://ibb.co/8dDBQ4m)

It seems like there is a separate effect for both turn rate and enemy top speed. Also, the effect seems reasonably linear.

(https://i.ibb.co/j5wCpCW/image.png) (https://ibb.co/fSN3c3n)

So we might fit a linear model here and use the coefficients to adjust hit probability for enemy top speed and turret turn rate. Note that the graphs show that turn rate really does not matter that much vs the real Dominator (top speed 30). This graph has me thinking Advanced turret gyros should be worth taking almost routinely.

Here is a plot for Glimmer (width 78 px). Broadly similar but with a floor as there comes a point where we can no longer hit with an immobile weapon just by rotating the Conquest.

(https://i.ibb.co/445cBb8/image.png) (https://ibb.co/1Rw3HCr)

Incidentally, if you are wondering whether the minimum distance and our ship keeping distance assumptions are necessary, the model behaves pretty badly without them
Spoiler
Without a minimum distance and without our ship keeping distance
(https://i.ibb.co/ngkrYzh/image.png) (https://ibb.co/kHcKwQY)
[close]
The weird curves are likely simply due to the enemy ship passing through or behind our ship a lot, throwing aim completely off, if we can catch up to it and we don't have assumptions about minimum distance the enemy can't pass through or a distance our ship is trying to keep.

So what kind of an equation would this be? Let's use the dominator data and find out.


df$hitprob <- df$hitprob/max(df$hitprob)
library(interactions)

lm <- lm(hitprob ~ topspeed*turnrate,data = df)
png("interactplot.png")
interact_plot(lm, pred = topspeed, modx = turnrate, linearity.check = TRUE,
              plot.points = TRUE)
dev.off()
summary(lm)



interaction plot
(https://i.ibb.co/BZ5cZqd/image.png) (https://imgbb.com/)
[close]
output
 summary(lm)

Call:
lm(formula = hitprob ~ topspeed * turnrate, data = df)

Residuals:
     Min       1Q   Median       3Q      Max
-0.11553 -0.03131  0.01024  0.03508  0.07836

Coefficients:
                    Estimate Std. Error t value Pr(>|t|)   
(Intercept)        8.835e-01  1.451e-02  60.877  < 2e-16 ***
topspeed          -1.688e-03  1.211e-04 -13.931  < 2e-16 ***
turnrate           3.583e-03  8.050e-04   4.452 1.76e-05 ***
topspeed:turnrate  4.657e-05  6.720e-06   6.930 1.53e-10 ***
---
Signif. codes:  0 %u2018***%u2019 0.001 %u2018**%u2019 0.01 %u2018*%u2019 0.05 %u2018.%u2019 0.1 %u2018 %u2019 1

Residual standard error: 0.04585 on 136 degrees of freedom
Multiple R-squared:  0.8453,   Adjusted R-squared:  0.8418
F-statistic: 247.6 on 3 and 136 DF,  p-value: < 2.2e-16

[close]
So let's plot the function 8.835e-01-1.688e-03*s+3.583e-03*t+4.657e-05*s*t


plot code

functionplot <- data.frame()
8.835e-01-1.688e-03*s+3.583e-03*t+4.657e-05*s*t
for (s in seq(0,200,1)){
  for (t in seq(0,30,0.5)){
    y <- 8.835e-01-1.688e-03*s+3.583e-03*t+4.657e-05*s*t
    print(s)
    functionplot <- rbind(functionplot, c(y,s,t))
}
}
functionplot
names(functionplot) <- c("y","s","t")
ggplot(functionplot,aes(x=s,y=y,col=t))+
  geom_point()+
  scale_color_viridis_c()
[close]

(https://i.ibb.co/GWj07ZR/image.png) (https://ibb.co/YcG325h)
s speed, t turn rate, y factor of hit probability

So that is a suggestion based on the model of a factor that should be included as a multiplier of the default hit rate vs. Dominator-sized ships based on enemy ship top speed and turret turn rate.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 18, 2022, 08:47:30 AM
I mean, you aren't considering shot travel time/projectile velocity (which is huge if not primary factor in missing), so it definitely isn't that complicated. It's really just asking at each simulation step if the turret rotation rate is fast enough to track the instantaneous angular velocity of the target (more or less). The proportion of time that is true is approximately the modifier on the hit rate.

You're then claiming that the proportion of time in this simulation that is true/false is the same as the proportion in the game. I just don't see why that would be necessarily true. It seems very plausible that could be quite different in the game.

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 18, 2022, 09:05:10 AM
I mean with regard to travel time you can just assume that this represents where the turret should be if travel time were accounted for, right? Given the x and y coordinates are not really tied to anything. So I don't really see why that would matter for describing turret rotation. However, Vanshilar found that to be a minor component only earlier in the previous thread so that would be the main reason to not worry too much about it. Essentially shots generally travel only a fraction of a second so most of the time that is not the main issue.

Whether it's the same as in the game - well, if you assume the enemy ship movement is generally described by some normal distribution (in aggregate, even though it might not seem so during one engagement) then this method would tell you the more difficult to calculate quantity of how that would affect hit rate. So it's not "necessarily true" in a logical sense at all. Only an attempt to provide a concise and usable formulation of it, maybe it makes sense and maybe not but it's a theory and was fun to make.

Better ideas for the math?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 18, 2022, 10:21:05 AM
I wasn't saying it was wrong, just saying the interaction itself isn't really complicated, and the thing that is actually being analyzed is how frequently the specific random motion generates certain relative velocity conditions, and that frequency is not clearly related to the actual game.

Also, saying 'shots only travel for a fraction of a second' is not evidence that velocity is negligible. Ships can also completely change course in fractions of a second. It's question of the relative values of those things. It's definitely pretty possible (and common) to dodge projectiles at moderate/long range by simply strafing when the enemy fires, which is the effect I am talking about.

The question is 'if I use current velocity to predict the targets future position and aim there, but then the target accelerates after I fire, how far off will my shot be?'.


But like I've said before, by far the most useful thing to add to the simulation IMO is simply the enemy returning fire. DPS checks for shields don't mean much without the associated flux dynamics. I did some analysis of pure shield/flux dynamics a while ago, took me forever to find the thread (https://fractalsoftworks.com/forum/index.php?topic=19099.0)

The actual analysis is a few comments down, not the main topic, and focused on looking at the value of caps vs vents, so I'll just copy paste the gist of it.

Spoiler
Consider two ships engaging one another. We assume that the ships engage and fire until overload but we make no claim that this is a realistic model of combat, merely that it is useful for analyzing who is more capable of winning the engagement in a brawl.  One possible metric that approximates the degree to which ship 1 defeats ship 2 in the flux war is the relative time to overload. Increasing this value indicates improvement in performance for ship 1. The difference between the time to overload of two ships determines who will overload first. Time to overload for a single ship is generally:

TTO = total capacity/flux generation = total capacity/(shield upkeep + Incoming flux from damage + net weapon flux generation)

The terms are all generally functions of ship characteristics like shield efficiencies, weapons, skills and hull mods.

TTO has the general form
TTO = (A + 200*#caps)/(B - 10*#vents)
with
A = the base capacity,
B = a sum of shield upkeep, flux generation due to enemy weapons, flux generation due to friendly weapons, minus base dissipation)
(There's actually a bit more complexity here as if dissipation exceeds soft flux generation, there is no additional benefit. This is easy to simulate, but annoying to write down analytically so I've ignored it here, but it is simulated correctly)
[close]

That was handy because it didn't require discrete simulation of combat like armor does, so I wouldn't say it's directly applicable to this simulation. The point is that what matters in the shield battle is actually the ratio of how much flux you're generating in your own ships vs the opponent along with the capacities of the ships. All the DPS in the world won't matter if you generate more flux so that you still max out first. I think it is very important to consider your own flux levels and your opponent returning fire in any realistic combat simulation, and certainly for any attempt at choosing a good loadout.

Btw, is there a GitHub for the python code? I have zero interest in learning R (I already know enough slow high level languages lol), but I've worked with python before.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 18, 2022, 10:36:10 PM
Liral was talking about making a GitHub and it would definitely be a good idea if multiple people want to contribute. Sadly I haven't had a chance to learn Python yet, maybe later. But you can definitely use whatever models I've put out if it seems worthwhile.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on November 19, 2022, 01:26:01 AM
This graph has me thinking Advanced turret gyros should be worth taking almost routinely.

I'm not seeing much effect of having turret gyros or not.

I took my 5-Conquest fleet (me in flagship Medusa, along with Gryphon as flanker) out and tried a couple of different variations against the 2-Ordos fleet. In all cases, the Conquests had Squall, Locust, Mjolnir, Gauss, HVD, Heavy Mauler, and 2 Harpoons. In each of these cases, the enemy fighters were removed (through modifying ship_data.csv to not have any fighter bays), and hulks were removed as soon as they appeared via repeatedly using the "removehulks" command whenever a ship died. Thus, the reasons for non-hits are misses (ship wasn't where the projectile was headed) and overkill (ship died while projectile was en route), and eliminating fighters and hulks as causes for missing.

The 3 cases were:
1. Without turret gyros. This means the Conquests had 29400 flux capacity each.
2. With turret gyros, which means the Conquests had 27400 instead of 29400 flux capacity (i.e. accounting for the fact that turret gyros take up OP).
3. With turret gyros built-in as a 4th s-mod, essentially getting the turret gyro hullmod for free (i.e. retaining 29400 flux capacity).

The results were as below:

Code
No enemy fighters, remove hulks, no turret gyros (SS 4710)
shield armor hull hits fired hitrate time %squall weapon
150821 8460 18140 853 1560 54.68% 3.380 100.00% squall
47207 10106 52730 3027 5190 58.32% 2.566 75.92% locust
57825 36405 61989 588 747 78.71% 1.868 55.25% mjolnir
78687 13555 34173 247 352 70.17% 2.347 69.43% gauss
39575 3686 13451 287 353 81.30% 2.353 69.63% HVD
9820 19148 14378 330 456 72.37% 2.533 74.95% heavy mauler
38672 23994 51449 233 344 67.73% 0.860 25.44% harpoon

No enemy fighters, remove hulks, with turret gyros costing 10 OP (SS 4748)
shield armor hull hits fired hitrate time %squall weapon
188287 9449 16738 998 1762 56.64% 3.818 100.00% squall
47958 12082 60546 3369 5505 61.20% 2.722 71.30% locust
61259 36099 54801 584 697 83.79% 1.743 45.64% mjolnir
85253 14662 38267 265 386 68.65% 2.573 67.41% gauss
43385 3477 12934 279 337 82.79% 2.247 58.85% HVD
10601 18365 13007 334 441 75.74% 2.450 64.18% heavy mauler
23933 22877 42788 170 292 58.22% 0.730 19.12% harpoon

No enemy fighters, remove hulks, with turret gyros for free (SS 4824)
shield armor hull hits fired hitrate time %squall weapon
183502 10871 18071 994 1660 59.88% 3.597 100.00% squall
49771 10355 61249 3176 5358 59.28% 2.649 73.66% locust
64354 36178 61211 611 748 81.68% 1.870 51.99% mjolnir
84069 14829 43667 270 398 67.84% 2.653 73.77% gauss
46394 3473 14685 290 353 82.15% 2.353 65.43% HVD
8490 22312 16665 341 453 75.28% 2.517 69.97% heavy mauler
24686 13529 35082 154 264 58.33% 0.660 18.35% harpoon

Of note, I realized that in my past data, the reason why Locusts had such a high uptime was because I forgot to account for that they get +50% fire rate due to Missile Spec. I had that factor for the Squalls but not for the Locusts. (Found it weird that even without fighters around, Locusts still had way too high of an uptime.) So some of my past stats for the Locust are incorrect for the time and %squall values, both of which need to be multiplied by 2/3; but the rest are correct.

Note that these values above are *not* valid for the typical Ordos fight, since they're with enemy fighters and hulks removed, so the actual hit rate is lower. The Mjolnir seems to have improved by around 4%, the Gauss got worse by around 2%, the HVD improved by around 1%, and the Heavy Mauler improved by around 3% in terms of their hit rates. So overall, an average of around a 1.6% improvement when the base rate was around 76% hit rate -- so it's small enough that it's unclear if this is just due to random error, since I only ran these fights once each. That the Gauss actually got slightly worse for example might mean that it was "luckier" during the first fight compared to the other two.

So I don't really see taking the hullmod as making a meaningful improvement in this case.

The question then is what is the cause of the remaining misses. The Gauss has the highest projectile velocity out of all of them (1200, compared with 900 for Mjolnir, 1000 for HVD, and 900 for Heavy Mauler), plus no spread, so it should be the most accurate when fired. However, its hit rate is consistently lower than the others. The two biggest factors are its low turn rate and its chargeup. It's the only one here with a chargeup of 1 second before firing, whereas the other weapons have 0 chargeup delay. The reason why this is crucial is that when a weapon isn't firing, its turn rate is 5 times (I think) its normal turn rate. However, when it's firing or when it's charging up to fire or during cooldown, its turn rate is what's given in weapon_data.csv. So the other weapons will have a very fast turn rate just before firing, so the shot will be reasonably accurate. After each shot, if the target has moved too far off-target, they'll simply stop firing and track at the 5x rate before firing again. This means that even the Hellbore, the slowest one of the bunch (other than Gauss) at 5 degrees per second, will be tracking at 25 degrees per second just before it fires. Most weapons will be tracking at 50 degrees per second or higher before they fire.

For the Gauss, it's different. When it thinks it's ready to fire, there'll be a 1-second delay while it's charging up, and it'll be tracking at 3 degrees per second during that time, before firing. So that 3 degrees per second is all the correction it gets during that 1 second delay to account for target movement and the firing ship turning. If a target is 1000 su away, that amounts to 52.4 su. So yeah unless the firing ship is helping the Gauss track a target, it's going to miss quite a bit.

To test for this, I did the above test again, with turret gyros for free (i.e. as the 4th built-in s-mod), but this time, I changed the Gauss's chargeup from 1 to 0 and chargedown from 1 to 2. So it still has a refire delay of 2 seconds, but now it fires right away and then cooldowns for 2 seconds, instead of charging up for a second beforehand then a second after. The result was:

Code
No enemy fighters, remove hulks, with turret gyros for free, Gauss set to chargeup 0, chargedown 2 (SS 4863)
shield armor hull hits fired hitrate time %squall weapon
167454 9635 22689 954 1667 57.23% 3.612 100.00% squall
51496 10317 54776 3137 5460 57.45% 2.700 74.75% locust
55386 32846 56820 540 719 75.10% 1.798 49.77% mjolnir
103068 20057 41243 322 409 78.73% 2.727 75.49% gauss
38429 3629 11576 268 340 78.82% 2.267 62.76% HVD
9416 18399 13521 314 441 71.20% 2.450 67.83% heavy mauler
29587 16313 37348 177 284 62.32% 0.710 19.66% harpoon

The Gauss ended up having one of the best hit rates. So yeah, the chargeup is the reason why its hit rate is lower. Putting turret gyros on won't really help all that much; there's probably something better to do with that 10 OP (put into caps if nothing else).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 19, 2022, 02:09:39 AM
Oh, I did not know that about turn rates and chargeup. Makes perfect sense! So 1) the model is off, since it assumes we are using base turnrate all the time and 2) seems like Gauss has an entirely different reason for missing that I didn't account for (chargeup)

Anyway, should then use 5 x t if you want to compare to graph above, so the prediction becomes different: we should only see significant differences when the enemy is routinely moving at 100 px/sec or more. (Since even Gauss, the slowest, will be on the t=15 curve for tracking the enemy - but of course, the model applies poorly to Gauss due to chargeup.) Obviously that makes turret gyros much more relative.

Thanks for the data, it's been invaluable on several occasions.

Edit to add: is there a way to get movement speed data out of those simulations?

If we assume the AI movement is distributed normally over a large number of moves, then we can use the above model to model weapon accuracy due to it drawing samples of movement from the same distribution as the AI. So that will let us model weapon accuracy vs moving ships via process above. But we can only make educated guesses about what the parameters of the normal distribution of AI moves across a very large number of moves are and above I guessed sd=topspeed/2 (based on the idea that it should be rare for the ship to move above its top speed). But that might be wrong and that would of course mean the model is not accurate because of that too.

Is there a way to get an answer to the question: during an average combat, what is the distribution of the AI's movement speed compared to top speed? Is it moving at an average of 1/3, 1/4, 1/2 of top speed?

(E2: re-read table and your post)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 19, 2022, 06:32:01 PM
Btw, is there a GitHub for the python code?

Liral was talking about making a GitHub and it would definitely be a good idea if multiple people want to contribute.

A GitHub is useful only if CapnHector writes research code in Python because I don't know anyone else willing to contribute unless intrinsic_party has hereby volunteered; else, I suppose each will post code to the thread.

Python Data Loading Code
import os
import csv
import json
from sys import platform


def vanilla_data_path():
    """
    Return the path from the mods folder to to the
    vanilla data folder.
   
    Path depends on OS.
    """
    if platform == "linux": return #linux path
    elif platform == "darwin": return #OS X path
    elif platform == "win32": return #windows path


def sub_directory_paths(path):
    """
    Return a list of the relative paths of the
    directories within this one.
    """
    return [f.path for f in os.scandir(path) if f.is_dir()]


def csv_dictionary(path):
    """
    Return a nested dictionary representation of
    the csv at the path.
    """
    with open(path) as f:
        reader = csv.reader(f)
        return {row[0] : {reader[0] : v for i, v in enumerate(row)}
                         for row in reader[1:] if row[0] != ""}


def dictionary(path):
    """
    Return the data of the ships and weapons of this
    source, organized by origin.
    """
    dictionary = {}
    os.chdir("weapons")
    dictionary["weapon_data.csv"] = csv_dictionary("weapon_data.csv")
    os.chdir("../hulls")
    dictionary["ship_data.csv"] = csv_dictionary("weapon_data.csv")
    dictionary[".ship"] = {ship_id : json.load(ship_id + ".ship") for
                          ship_id in dictionary[path]["ship_data.csv"]}
    return dictionary
   

def data():
    """
    Consolidate the ship and weapon data of vanilla
    (and every mod selected by the user) into one
    dictionary for simulation and calculation use.

    The structure of this dictionary is

    {
        "sourceId":{
            "ships":{
                "shipId":{
                    "attributeId":value
                }
            },
            "weapons" : {
                "weaponId":{
                    "attributeId":value
                }
            }
        }
    }

    where sourceId is "vanilla" or the
    corresponding mod id and every shipId, weaponId,
    and attributeId is taken from the game or mod
    files.
    """
    data = {}
    root = os.path.abspath(os.getcwd())
    os.chdir("..")
    for path in sub_directory_paths():
        os.chdir(path)
        data[path] = dictionary(path)
        os.chdir("..")
    os.chdir(vanilla_data_path())
    add_data(mod_path, data)
[close]

Quote
I have zero interest in learning R (I already know enough slow high level languages lol), but I've worked with python before.

Sadly I haven't had a chance to learn Python yet, maybe later. But you can definitely use whatever models I've put out if it seems worthwhile.

I knew Python but not R when I started and no interest in learning it, either, but learned R in one sitting because R and Python have almost the same syntax.  I've even posted a handy guide to the handful of differences to this thread.  Our language barrier is trivially surmounted.  8)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 19, 2022, 06:55:11 PM
I figure GitHub might be nice if the ultimate goal is making something more broadly useful to the community. I wouldn't mind contributing either.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 19, 2022, 10:03:00 PM
Don't we basically have all the pieces now? Since we now have methods to calculate damage, a method to generate discrete sequences of shots/sec from basic values for beams and guns (my posts before the accuracy thing). The turn rate thing can be skipped for now since the output of that analysis - if any is reached - will be 1 straightforward multiplier to apply to hitrate based on enemy top speed and turret turn rate.

So to make the tool, what's needed now are tools to import the relevant values e.g. ship width, and a few minor improvements to the damage code (beam weapons, soft flux) - right? What's the most up to date version of the damage code?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 20, 2022, 06:20:04 AM
Don't we basically have all the pieces now? Since we now have methods to calculate damage, a method to generate discrete sequences of shots/sec from basic values for beams and guns (my posts before the accuracy thing). The turn rate thing can be skipped for now since the output of that analysis - if any is reached - will be 1 straightforward multiplier to apply to hitrate based on enemy top speed and turret turn rate.

So to make the tool, what's needed now are tools to import the relevant values e.g. ship width, and a few minor improvements to the damage code (beam weapons, soft flux) - right? What's the most up to date version of the damage code?

The Python code I wrote imports all the values as a giant dictionary, sorted by
{
    mod_id:{
        weapon_data.csv:{
            weapon_id:{
                column_id:value
            }
        },
        ship_data.csv:{
            ship_id:{
                column_id:value
            }
        },
        some_ship.ship:{
            #ship file contents
        },
        other_ship.ship:{
            #ship file contents
        },
        ...
    }
}

Access the dictionary by writing data[mod_id][origin][ship_or_weapon_id][attribute_id], with the same .ship file being further nested as in the .ship file itself.  Note that all the values are strings.  I could write a wrapper class with accessor methods if desired.

The most up to date Python version of the damage code has none of these features you have mentioned.  I have been awaiting your R version to translate.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 20, 2022, 07:36:57 AM
Whoops, sorry, didn't realize I was the hold-up. Try to add asap. I have one more question, does anybody know the answer? If a ship has x hard flux and y soft flux and lowers shields, regenerating z flux, then how is z proportioned among hard flux and soft flux? After 1 second, is it
a) x <- x-z, y <- y, if x > z, else x -> 0 and y -> y-(z-x) (ie. hard flux goes first)
b) x <- x-z(x/(x+y)), y <- y-z(y/(x+y)) (ie. dissipated proportionally)
c) y <- y-z, x <- x, if y > z, else y -> 0 and x -> x-(z-y) (ie. soft flux goes first)
something else entirely?

List of features to implement
- Code must include a parameter for shield radius
- Beams: beams hit with dps/2 hit str but do damage according to their shot distribution, and always in 1 shot
- Soft flux: dissipate only soft on turns when using shields, else hard also

I am also thinking beams shouldn't have the SD error thing unlike ballistics, because beams seem to have perfect tracking when no spread. Edit: maybe they still should cause it includes enemy rotation, too.

Comments?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on November 20, 2022, 09:39:09 AM
I have one more question, does anybody know the answer? If a ship has x hard flux and y soft flux and lowers shields, regenerating z flux, then how is z proportioned among hard flux and soft flux?

Soft flux is always dissipated before any hard flux is dissipated. So if there's any soft flux, it gets counted first. Only what's left over goes toward hard flux, and only if the shields are down. (If the shields are up, then no hard flux gets removed.) If the target has Field Modulation, then it's that once there's only hard flux left, if shields are up, then 15% of the flux dissipation goes toward hard flux, instead of 0%.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 20, 2022, 03:03:04 PM
Whoops, sorry, didn't realize I was the hold-up. Try to add asap. I have one more question, does anybody know the answer? If a ship has x hard flux and y soft flux and lowers shields, regenerating z flux, then how is z proportioned among hard flux and soft flux? After 1 second, is it
a) x <- x-z, y <- y, if x > z, else x -> 0 and y -> y-(z-x) (ie. hard flux goes first)
b) x <- x-z(x/(x+y)), y <- y-z(y/(x+y)) (ie. dissipated proportionally)
c) y <- y-z, x <- x, if y > z, else y -> 0 and x -> x-(z-y) (ie. soft flux goes first)
something else entirely?

List of features to implement
- Code must include a parameter for shield radius
- Beams: beams hit with dps/2 hit str but do damage according to their shot distribution, and always in 1 shot
- Soft flux: dissipate only soft on turns when using shields, else hard also

I am also thinking beams shouldn't have the SD error thing unlike ballistics, because beams seem to have perfect tracking when no spread. Edit: maybe they still should cause it includes enemy rotation, too.

Comments?

I think you're adding necessary features, and I want modders to be able to limit execution time enough for iterative workflow: run the tool, comprehend the output, evaluate balance accordingly, try something to improve the balance, repeat.  Immediately displaying understandable, quantitative consequences of balance decisions across many matchups could reduce the time and effort modders need to find the ship or weapon attribute values that would leave users with diverse and interesting choices and flag some counterintuitive or simply-neglected potential problems before release.  If we included a flag that would limit iteration to variants, I bet the tool could run fast enough for these purposes.

Here's an example: testing the variants 10 ships from a mod against the variants of a 'standard group' of 100 ships would entail (4 variants x 10 mod ships) x (4 variants x 100 group ships) = 40 x 400 = 16,000 matchups.  If each matchup ran in one millisecond, then one worker would need just 16 seconds to run the test.  Four workers could do it in 4 seconds—fast enough for modders to tolerate repeatedly.  We have nearly made something useful!  ;D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 20, 2022, 08:42:55 PM
Another idea is to write the function in C++ (weirdly enough the only other language I have experience in writing, other than shell scripts) and embed it into the R code using Rcpp. This might increase performance by such as a factor of 10. But I won't be doing it now since I don't know if that's possible in Python.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 20, 2022, 09:05:34 PM
I'm pretty sure everything vaguely performant in python is actually calling a C or C++ library or function lmao. But I've never tried doing something like that myself.

I also know that cython exists https://cython.org (https://cython.org), but I've never used it.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 20, 2022, 10:53:56 PM
Alright then, working on it. Here is the new armor damage function
Code
#this function is absolutely performance critical (run millions of times) so write it in C++ using
#elementary operations
library(Rcpp)
#what this function shall do is:
#1 ) modify the armor matrix in the R global environment by subtracting armor damage
#2 ) return hull damage
#A is the armor matrix
#rows_A is the number of rows in A
#cols_A is the number of columns in A
#D is the damage matrix (note: we do not need a rows_D etc as these matrices must be same size)
#a is starting armor for the whole ship
#a_mod is minimum armor
#d is raw damage from whole weapon shot
#h is hit strength of weapon shot
#m is modifier, 2= kinetic, 1 = energy, 0.5 = he, 4 = frag
#hd is hull damage
#do NOT pass a hit strength of 0 to this function as it does not check for dividing by zero
#overall, this function does no sanity or safety checking so be careful with it
cppFunction('double damage(NumericMatrix A, NumericMatrix D, double a, double a_mod, double d, double h, double m){
  double hd = 0;
  int rows_A = A.nrow();
  int cols_A = A.ncol();
  a = a/(rows_A*cols_A);
  for (int j = 0; j < cols_A; j++){
  for (int i = 0; i < rows_A; i++){
    double armor = std::max(A(i,j), a_mod*a);
    double probmult = D(i,j);
    double adjusted_d = d*h/m/(armor+h/m)*probmult;
    if (adjusted_d <= armor){
      A(i,j) = A(i,j) - adjusted_d;
      A(i,j) = std::max(A(i,j), 0.0);
    }
    if (adjusted_d > armor){
      A(i,j) = 0.0;
      adjusted_d = (adjusted_d - armor)*m;
      hd = hd + adjusted_d;
    }
  }
  }
  return hd;
}
')

Edit: Here is WIP. Working on the script to produce a graph first to test that all is working correctly. To be continued. Currently only works for one Squall in slot 1 but includes what I intend to do to handle beam weapons. E2: got it I think. Here is a combo of squall x 2 and "super ion beam" (ion beam except 1000 dps). E3: added a single check to determine whether we are using shield to block this second rather than if we are using it to block this weapon (ie. you are not allowed to let missiles through while blocking the beam with shield). E4: added multiply by beam_tick_time to beam damage which I had forgotten.
(https://i.ibb.co/bsVt4rv/image.png) (https://ibb.co/y6C2z8k)

Spoiler
Code

library(ggplot2)
library(ggthemes)

#operating modes DO NOT CHANGE CODE WILL BREAK IF GUN IS NOT 0 AND BEAM IS NOT 1 AS THE LITERAL INTEGER VALUE IS USED

GUN <- 0
BEAM <- 1
#SHIP
#dominator, hullhp, shieldregen, shieldmax, startingarmor, widthinpixels, armorcells, shieldwidth, shieldefficacy, shieldupkeep
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#engagementrange
range <- 1000

minimumarmormultiplier <- 0.05

#weaponaccuracy - this will be made a function of time and weapon later. the accuracy of a hellbore is 10
acc <- 10

#fudge factor
errorsd <- 0.05
#the fudge factor should be a function of range (more error in position at greater range), but not a function of weapon firing angle, and be expressed in terms of pixels
error <- errorsd*range

#time limit for a single combat
time_limit <- 30



#how much is the visual arc of the ship in rad?
shipangle <- ship[5]/(2* pi *range)

#how much is the visual arc of a single cell of armor in rad?
cellangle <- shipangle/ship[6]

#now assume the weapon is targeting the center of the ship's visual arc and that the ship is in the center of the weapon's firing arc
#which cell will the shot hit, or will it miss?
#call the cells (MISS, cell1, cell2, ... ,celli, MISS) and get a vector giving the (maximum for negative / minimum for positive) angles for hitting each
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle

#now convert it to pixels
anglerangevector <- anglerangevector*2*pi*range

#this vector will store the hits
shipcellvector <- vector(mode="double", length = ship[6]+2)




G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))


# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)
hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds))
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds)-1)) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds)-1)) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)-1], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      for (j in 1:(length(upperbounds)-1)) vector[j] <- pnorm(upperbounds[j], mean=0, sd=standard_deviation)
      for (j in 2:(length(upperbounds)-1)) vector[j] <- vector[j] - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)] <- 1-pnorm(upperbounds[length(upperbounds)-1], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#this is not really necessary, just a wrapper for the above new function to fit into the old code
createdistribution <- function(acc,mode){
  return(hit_distribution(anglerangevector,error,acc))
}

# this is the default distribution of damage to armor cells
b <- matrix(0,nrow=5,ncol=5)
b[1:5,2:4] <- 1/30
b[2:4,1:5] <- 1/30
b[2:4,2:4] <- 1/15
b[1,1] <- 0
b[1,5] <- 0
b[5,1] <- 0
b[5,5] <- 0

#this function returns the chance to hit a simple width
hitchance <- function(acc,error,lowerpoint,higherpoint){
  return(hit_distribution(c(lowerpoint,higherpoint,0),error,acc)[[2]])
}
#this function generates a sum of matrices multiplied by the distribution

createhitmatrix <- function(acc){
  hitmatrix <- matrix(0,5,ship[6]+4)
  distributionvector <- createdistribution(acc)
  for (i in 1:ship[6]){
    hitmatrix[,i:(i+4)] <- hitmatrix[,i:(i+4)]+b*(distributionvector[i+1])
  }
  return(hitmatrix)
}

#for weapons with damage changing over time we need a sequence of matrices
createhitmatrixsequence <- function(accvector){
  hitmatrixsequence <- list()
  for (i in 1:length(accvector)){
    hitmatrixsequence[[i]] <- createhitmatrix(accvector[i])
  }
  return(hitmatrixsequence)
}
 

#this function is absolutely performance critical (run millions of times) so write it in C++ using
#elementary operations
library(Rcpp)
#what this function shall do is:
#1 ) modify the armor matrix in the R global environment by subtracting armor damage
#2 ) return hull damage
#A is the armor matrix
#rows_A is the number of rows in A
#cols_A is the number of columns in A
#D is the damage matrix (note: we do not need a rows_D etc as these matrices must be same size)
#a is starting armor for the whole ship
#a_mod is minimum armor
#d is raw damage from whole weapon shot
#h is hit strength of weapon shot
#m is modifier, 2= kinetic, 1 = energy, 0.5 = he, 4 = frag
#hd is hull damage
#do NOT pass a hit strength of 0 to this function as it does not check for dividing by zero
#overall, this function does no sanity or safety checking so be careful with it
cppFunction('double damage(NumericMatrix A, NumericMatrix D, double a, double a_mod, double d, double h, double m){
  double hd = 0;
  int rows_A = A.nrow();
  int cols_A = A.ncol();
  a = a/(rows_A*cols_A);
  for (int j = 0; j < cols_A; j++){
  for (int i = 0; i < rows_A; i++){
    double armor = std::max(A(i,j), a_mod*a);
    double probmult = D(i,j);
    double adjusted_d = d*h/m/(armor+h/m)*probmult;
    if (adjusted_d <= armor){
      A(i,j) = A(i,j) - adjusted_d;
      A(i,j) = std::max(A(i,j), 0.0);
    }
    if (adjusted_d > armor){
      A(i,j) = 0.0;
      adjusted_d = (adjusted_d - armor)*m;
      hd = hd + adjusted_d;
    }
  }
  }
  return hd;
}
')

#general function to generate ticks
#1. general constants
#the interval of discrete time (time lattice parameter) we are using in the model, in seconds
time_interval <- 1
#how long 1 tick of a beam lasts, in seconds
beam_tick <- 1/10
#minimum interval that exists in the game, in case a modder has somehow specified a lower value for something
global_minimum_time <- 0.05
#operating modes
UNLIMITED <- -1

#times in seconds, ammoregen is in ammo / second
hits <- function(chargeup, chargedown, burstsize, burstdelay, ammo=UNLIMITED, ammoregen=0, reloadsize=0, traveltime=0, mode=GUN){
  #specify sane minimum delays, since the game enforces weapons can only fire once every 0.05 sec
  #for beams, refiring delay is given by burstdelay, for guns it is burstdelay in case burstdelay is > 0 (==0 is shotgun) and chargedown
  if(burstdelay > 0 | mode == BEAM) burstdelay <- max(burstdelay, global_minimum_time)
  if(mode == GUN) chargedown <- max(chargedown, global_minimum_time)
  #this vector will store all the hit time coordinates
  #current time
  #insert a very small fraction here to make time round correctly
  time <- 0.001
  #maximum ammo count is ammo given at start
  maxammo <- ammo
  #this is used to do ammo regeneration, 0 = not regenerating ammo, 1 = regenerating ammo
  regeneratingammo <- 0
  ammoregentimecoordinate <- 0
  ammoregenerated <- 0
 
  #we are firing a gun
  if (mode == GUN) {
    Hits <- vector(mode="double", length = 0)
    while(time < time_limit){
      time <- time + chargeup
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
     
      if (burstdelay == 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            ammo <- ammo - 1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
          }
        }
      }
      if (burstdelay > 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            time <- time + burstdelay
            ammo <- ammo -1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
           
          }
        }
      }
      time <- time+chargedown
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
    }
    timeseries <- vector(mode="integer", length = time_limit/time_interval)
    timeseries[1] <- length(Hits[Hits >= 0 & Hits <= 1*time_interval])
    for (i in 2:time_limit/time_interval) timeseries[i] <- length(Hits[Hits > (i-1)*time_interval & Hits <= i*time_interval])
    return(timeseries)
  }
  #we are firing a beam
  if (mode == BEAM) {
    chargeup_ticks <- chargeup/beam_tick
    chargedown_ticks <- chargedown/beam_tick
    burst_ticks <- burstsize/beam_tick
    #for a beam we will instead use a matrix to store timepoint and beam intensity at timepoint
    beam_matrix <- matrix(nrow=0,ncol=2)
    #burst size 0 <- the beam never stops firing
    if(burstsize == 0){
      for (i in 1:chargeup_ticks) {
        #beam intensity scales quadratically during chargeup, so
      }
      while ( time < time_limit) {
        beam_matrix <- rbind(beam_matrix,c(time, 1))
        time <- time+beam_tick
      }
    } else {
      while (time < time_limit) {
        if (ammo != 0){
          ammo <- ammo - 1
          if (chargeup_ticks > 0){
            for (i in 1:chargeup_ticks) {
              beam_matrix <- rbind(beam_matrix,c(time, (i*beam_tick)^2))
              time <- time+beam_tick
              if (regeneratingammo == 0) {
                ammoregentimecoordinate <- time
                regeneratingammo <- 1
              }
              if(time - ammoregentimecoordinate > 1/ammoregen){
                ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
                ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
                if(ammoregenerated >= reloadsize){
                  ammo <- ammo+ ammoregenerated
                  ammoregenerated <- 0
                }
                if(ammo >= maxammo){
                  ammo <- maxammo
                  regeneratingammo <- 0
                }
              }
            }
          }
          for (i in 1:burst_ticks){
            beam_matrix <- rbind(beam_matrix,c(time, 1))
            time <- time+beam_tick
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
         
          if (chargedown_ticks > 0){
            for (i in 1:chargedown_ticks){
              beam_matrix <- rbind(beam_matrix,c(time, ((chargedown_ticks-i)*beam_tick)^2))
              time <- time+beam_tick
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
          time <- time + burstdelay
          if(time - ammoregentimecoordinate > 1/ammoregen){
            ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
            ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
            if(ammoregenerated >= reloadsize){
              ammo <- ammo+ ammoregenerated
              ammoregenerated <- 0
            }
            if(ammo >= maxammo){
              ammo <- maxammo
              regeneratingammo <- 0
            }
          }
        }
        time <- time + global_minimum_time
        if(time - ammoregentimecoordinate > 1/ammoregen){
          ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
          ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
          if(ammoregenerated >= reloadsize){
            ammo <- ammo+ ammoregenerated
            ammoregenerated <- 0
          }
          if(ammo >= maxammo){
            ammo <- maxammo
            regeneratingammo <- 0
          }
        }
      }
    }
    timeseries <- vector(mode="double", length = time_limit/time_interval)
    for (i in 1:length(timeseries)) {
      timeseries[i] <- sum(beam_matrix[beam_matrix[,1] < i & beam_matrix[,1] > i-1,2])
    }
    return(timeseries)
  }
}

squalltics <- hits(0,10,20,0.5)
locusttics <- hits(0,5,40,0.1)
#special
hurricanetics <- hits(0,15,9,0)
harpoontics <- hits(0,8.25,4,0.25)
sabottics <- hits(0,8.75,2,0.25)
gausstics <- hits(1,1,1,0)
ionbeamtics <- hits(0.1,0.1,0,0,mode=BEAM)

#WEAPON ACCURACY
#missiles do not have spread
squallacc <- c(0)
locustacc <- c(0)
hurricaneacc <- c(0)
harpoonacc <- c(0)
sabotacc <- c(0)

#gauss has a spread of 0 and no increase per shot
gaussacc <- c(0)
#hephaestus has a spread of 0 and it increases by 2 per shot to a max of 10
#hephaestusacc <- c(seq(0,10,2))
#mark ix has a spread of 0 and it increases by 2 per shot to a max of 15
#markixacc <- c(seq(0,15,2),15)
#mjolnir has a spread of 0 and it increases by 1 per shot to a max of 5
#mjolniracc <- c(seq(1,5,1))
#hellbore has a spread of 10
#hellboreacc <- c(10)
#storm needler has a spread of 10
#stormneedleracc <- c(10)
ionbeamacc <- c(0)

#damage per shot, damage type (2=kinetic, 0.5=he, 0.25=frag, 1=energy), tics, weapon name, weapon accuracy over time, hit chance, mode
squall <- list(250, 2, squalltics, "Squall", squallacc, GUN)
locust <- list(200, 0.25, locusttics, "Locust", locustacc, GUN)
hurricane <- list(500, 0.5, hurricanetics, "Hurricane", hurricaneacc, GUN)
harpoon <- list(750, 0.5, harpoontics, "Harpoon", harpoonacc, GUN)
sabot <- list(200, 2, sabottics, "Sabot", sabotacc, GUN)
gauss <- list(700, 2, gausstics, "Gauss", gaussacc, GUN)
#hephaestus <- list(120, 0.5, hephaestustics, "Hephaestus", hephaestusacc)
#markix <- list(200, 2, markixtics, "Mark IX", markixacc)
#mjolnir <- list(400, 1, mjolnirtics, "Mjolnir", mjolniracc)
#hellbore <- list(750, 0.5, hellboretics, "Hellbore", hellboreacc)
#stormneedler <- list(50, 2, stormneedlertics, "Storm Needler", stormneedleracc)

#for beams, damage per second, and then the rest as previously
ionbeam <- list(1000, 1, ionbeamtics, "Super Ion Beam", ionbeamacc, BEAM)
dummy <- list(0,0,c(seq(0,time_limit,1)),"",c(0),c(0),GUN)


weapon1 <- squall
weapon2 <- squall
weapon3 <- ionbeam
weapon4 <- dummy
weapon5 <- dummy
weapon6 <- dummy
weapon7 <- dummy
weapon8 <- dummy

#now create the sequences of hit matrices and hit chances for each weapon

if(weapon1[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon1[[5]]))
  for (i in 1:length(weapon1[[5]])){
    hitchancevector[i] <- hitchance(weapon1[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon1[[7]] <- hitchancevector
  weapon1[[8]] <- createhitmatrixsequence(weapon1[[5]])
}

if(weapon2[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon2[[5]]))
  for (i in 1:length(weapon2[[5]])){
    hitchancevector[i] <- hitchance(weapon2[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon2[[7]] <- hitchancevector
  weapon2[[8]] <- createhitmatrixsequence(weapon2[[5]])
}

if(weapon3[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon3[[5]]))
  for (i in 1:length(weapon3[[5]])){
    hitchancevector[i] <- hitchance(weapon3[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon3[[7]] <- hitchancevector
  weapon3[[8]] <- createhitmatrixsequence(weapon3[[5]])
}

if(weapon4[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon4[[5]]))
  for (i in 1:length(weapon4[[5]])){
    hitchancevector[i] <- hitchance(weapon4[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon4[[7]] <- hitchancevector
  weapon4[[8]] <- createhitmatrixsequence(weapon4[[5]])
}
if(weapon5[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon5[[5]]))
  for (i in 1:length(weapon5[[5]])){
    hitchancevector[i] <- hitchance(weapon5[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon5[[7]] <- hitchancevector
  weapon5[[8]] <- createhitmatrixsequence(weapon5[[5]])
}
if(weapon6[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon6[[5]]))
  for (i in 1:length(weapon6[[5]])){
    hitchancevector[i] <- hitchance(weapon6[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon6[[7]] <- hitchancevector
  weapon6[[8]] <- createhitmatrixsequence(weapon6[[5]])
}
if(weapon7[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon7[[5]]))
  for (i in 1:length(weapon7[[5]])){
    hitchancevector[i] <- hitchance(weapon7[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon7[[7]] <- hitchancevector
  weapon7[[8]] <- createhitmatrixsequence(weapon7[[5]])
}
if(weapon8[4] != ""){
  hitchancevector <- vector(mode = "double", length = length(weapon8[[5]]))
  for (i in 1:length(weapon8[[5]])){
    hitchancevector[i] <- hitchance(weapon8[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
  }
  weapon8[[7]] <- hitchancevector
  weapon8[[8]] <- createhitmatrixsequence(weapon8[[5]])
}


shieldblock <- 0

weapon1shots <- 1
weapon2shots <- 1
weapon3shots <- 1
weapon4shots <- 1
weapon5shots <- 1
weapon6shots <- 1
weapon7shots <- 1
weapon8shots <- 1


armormatrix <- matrix(ship[4]/15,5,ship[6]+4)

timeseries <- function(timepoint, softflux, hardflux, armorhp, hullhp, shieldregen, shieldmax, startingarmor,armormatrix){
  weaponacc <- 0

  #are we using shield to block?
  shieldblock <- 0
  hulldamage <- 0

 
  #weapon 1
  weapon1mult <- weapon1[[2]]
  weapon2mult <- weapon2[[2]]
  weapon3mult <- weapon3[[2]]
  weapon4mult <- weapon4[[2]]
  weapon5mult <- weapon5[[2]]
  weapon6mult <- weapon6[[2]]
  weapon7mult <- weapon7[[2]]
  weapon8mult <- weapon8[[2]]
 
  shots <- weapon1[[3]][[timepoint]]
  #here we must convert beam ticks to fractional shots
  shots1 <- weapon1[[3]][[timepoint]]*beam_tick_time^(weapon1[[6]])
  shots2 <- weapon2[[3]][[timepoint]]*beam_tick_time^(weapon2[[6]])
  shots3 <- weapon3[[3]][[timepoint]]*beam_tick_time^(weapon3[[6]])
  shots4 <- weapon4[[3]][[timepoint]]*beam_tick_time^(weapon4[[6]])
  shots5 <- weapon5[[3]][[timepoint]]*beam_tick_time^(weapon5[[6]])
  shots6 <- weapon6[[3]][[timepoint]]*beam_tick_time^(weapon6[[6]])
  shots7 <- weapon7[[3]][[timepoint]]*beam_tick_time^(weapon7[[6]])
  shots8 <- weapon8[[3]][[timepoint]]*beam_tick_time^(weapon8[[6]])
  #test is used to determine if we are firing or blocking this turn
  test <- (fluxcap-softflux-hardflux - weapon1[[1]]*weapon1mult*shots1 - weapon2[[1]]*weapon2mult*shots2 - weapon3[[1]]*weapon3mult*shots3 - weapon4[[1]]*weapon4mult*shots4 - weapon5[[1]]*weapon5mult*shots5 - weapon6[[1]]*weapon6mult*shots6 - weapon7[[1]]*weapon7mult*shots7 - weapon8[[1]]*weapon8mult*shots8)   
  #skip the whole thing if we are not firing
  if (weapon1[[4]] !="" & shots > 0){
    mode <- weapon1[[6]]
    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
        hardflux <- hardflux + weapon1[[1]]*weapon1mult*weapon1[[7]][min(weapon1shots,length(weapon1[[7]]))]*shieldefficacy
        hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon1[[1]]*weapon1[[3]][[timepoint]]*beam_tick_time*weapon1mult*weapon1[[7]][min(weapon1shots,length(weapon1[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
      #2. if you did not use shield to block, damage armor and hull
      #frag is a special case wrt multiplier
      if(unlist(weapon1[2])==0.25){weapon1mult = 4}
      #2.1. damage armor and hull
      hitstrength <- 0
      damage <- 0
      if(mode == GUN) {
        hitstrength <- weapon1[[1]]
        damage <- weapon1[[1]]
      }
      if(mode == BEAM) {
        hitstrength <- weapon1[[1]]/2
        damage <- weapon1[[1]]*weapon1[[3]][[timepoint]]*beam_tick_time
      }
      hulldamage <- damage(armormatrix,weapon1[[8]][[min(weapon1shots,length(weapon1[[8]]))]],startingarmor,minimumarmormultiplier,damage,hitstrength,weapon1mult)
      hullhp <- hullhp - hulldamage
      hullhp <- max(hullhp, 0)
      }
   }
  weapon1shots <- weapon1shots + weapon1[[3]][timepoint]
  }
 
  #repeat for other weapons
  shots <- weapon2[[3]][[timepoint]]
 
 
  #skip the whole thing if we are not firing
  if (weapon2[[4]] !="" & shots > 0){
    weapon2mult <- weapon2[[2]]
    mode <- weapon2[[6]]

    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
          hardflux <- hardflux + weapon2[[1]]*weapon2mult*weapon2[[7]][min(weapon2shots,length(weapon2[[7]]))]*shieldefficacy
          hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon2[[1]]*weapon2[[3]][[timepoint]]*beam_tick_time*weapon2mult*weapon2[[7]][min(weapon2shots,length(weapon2[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
        #2. if you did not use shield to block, damage armor and hull
        #frag is a special case wrt multiplier
        if(unlist(weapon2[2])==0.25){weapon2mult = 4}
        #2.1. damage armor and hull
        hitstrength <- 0
        damage <- 0
        if(mode == GUN) {
          hitstrength <- weapon2[[1]]
          damage <- weapon2[[1]]
        }
        if(mode == BEAM) {
          hitstrength <- weapon2[[1]]/2
          damage <- weapon2[[1]]*weapon2[[3]][[timepoint]]*beam_tick_time
        }
        hulldamage <- damage(armormatrix,weapon2[[8]][[min(weapon2shots,length(weapon2[[8]]))]],startingarmor,minimumarmormultiplier,weapon2[[1]],hitstrength,weapon2mult)
        hullhp <- hullhp - hulldamage
        hullhp <- max(hullhp, 0)
      }
    }
    weapon2shots <- weapon2shots + weapon2[[3]][timepoint]
  }
 
 
  shots <- weapon3[[3]][[timepoint]]
 
 
  #skip the whole thing if we are not firing
  if (weapon3[[4]] !="" & shots > 0){
    weapon3mult <- weapon3[[2]]
    mode <- weapon3[[6]]
    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
          hardflux <- hardflux + weapon3[[1]]*weapon3mult*weapon3[[7]][min(weapon3shots,length(weapon3[[7]]))]*shieldefficacy
          hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon3[[1]]*weapon3[[3]][[timepoint]]*beam_tick_time*weapon3mult*weapon3[[7]][min(weapon3shots,length(weapon3[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
        #2. if you did not use shield to block, damage armor and hull
        #frag is a special case wrt multiplier
        if(unlist(weapon3[2])==0.25){weapon3mult = 4}
        #2.1. damage armor and hull
        hitstrength <- 0
        damage <- 0
        if(mode == GUN) {
          hitstrength <- weapon3[[1]]
          damage <- weapon3[[1]]
        }
        if(mode == BEAM) {
          hitstrength <- weapon3[[1]]/2
          damage <- weapon3[[1]]*weapon3[[3]][[timepoint]]*beam_tick_time
        }
        hulldamage <- damage(armormatrix,weapon3[[8]][[min(weapon3shots,length(weapon3[[8]]))]],startingarmor,minimumarmormultiplier,weapon3[[1]],hitstrength,weapon3mult)
        hullhp <- hullhp - hulldamage
        hullhp <- max(hullhp, 0)
      }
    }
    weapon3shots <- weapon3shots + weapon3[[3]][timepoint]
  }
 
 
  shots <- weapon4[[3]][[timepoint]]
 
 
  #skip the whole thing if we are not firing
  if (weapon4[[4]] !="" & shots > 0){
    weapon4mult <- weapon4[[2]]
    mode <- weapon4[[6]]
    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
          hardflux <- hardflux + weapon4[[1]]*weapon4mult*weapon4[[7]][min(weapon4shots,length(weapon4[[7]]))]*shieldefficacy
          hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon4[[1]]*weapon4[[3]][[timepoint]]*beam_tick_time*weapon4mult*weapon4[[7]][min(weapon4shots,length(weapon4[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
        #2. if you did not use shield to block, damage armor and hull
        #frag is a special case wrt multiplier
        if(unlist(weapon4[2])==0.25){weapon4mult = 4}
        #2.1. damage armor and hull
        hitstrength <- 0
        damage <- 0
        if(mode == GUN) {
          hitstrength <- weapon4[[1]]
          damage <- weapon4[[1]]
        }
        if(mode == BEAM) {
          hitstrength <- weapon4[[1]]/2
          damage <- weapon4[[1]]*weapon4[[3]][[timepoint]]*beam_tick_time
        }
        hulldamage <- damage(armormatrix,weapon4[[8]][[min(weapon4shots,length(weapon4[[8]]))]],startingarmor,minimumarmormultiplier,weapon4[[1]],hitstrength,weapon4mult)
        hullhp <- hullhp - hulldamage
        hullhp <- max(hullhp, 0)
      }
    }
    weapon4shots <- weapon4shots + weapon4[[3]][timepoint]
  }
 
 
  shots <- weapon5[[3]][[timepoint]]
 
 
  #skip the whole thing if we are not firing
  if (weapon5[[4]] !="" & shots > 0){
    weapon5mult <- weapon5[[2]]
    mode <- weapon5[[6]]
    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
          hardflux <- hardflux + weapon5[[1]]*weapon5mult*weapon5[[7]][min(weapon5shots,length(weapon5[[7]]))]*shieldefficacy
          hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon5[[1]]*weapon5[[3]][[timepoint]]*beam_tick_time*weapon5mult*weapon5[[7]][min(weapon5shots,length(weapon5[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
        #2. if you did not use shield to block, damage armor and hull
        #frag is a special case wrt multiplier
        if(unlist(weapon5[2])==0.25){weapon5mult = 4}
        #2.1. damage armor and hull
        hitstrength <- 0
        damage <- 0
        if(mode == GUN) {
          hitstrength <- weapon5[[1]]
          damage <- weapon5[[1]]
        }
        if(mode == BEAM) {
          hitstrength <- weapon5[[1]]/2
          damage <- weapon5[[1]]*weapon5[[3]][[timepoint]]*beam_tick_time
        }
        hulldamage <- damage(armormatrix,weapon5[[8]][[min(weapon5shots,length(weapon5[[8]]))]],startingarmor,minimumarmormultiplier,weapon5[[1]],hitstrength,weapon5mult)
        hullhp <- hullhp - hulldamage
        hullhp <- max(hullhp, 0)
      }
    }
    weapon5shots <- weapon5shots + weapon5[[3]][timepoint]
  }
 
  shots <- weapon6[[3]][[timepoint]]
 
 
  #skip the whole thing if we are not firing
  if (weapon6[[4]] !="" & shots > 0){
    weapon6mult <- weapon6[[2]]
    mode <- weapon6[[6]]
    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
          hardflux <- hardflux + weapon6[[1]]*weapon6mult*weapon6[[7]][min(weapon6shots,length(weapon6[[7]]))]*shieldefficacy
          hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon6[[1]]*weapon6[[3]][[timepoint]]*beam_tick_time*weapon6mult*weapon6[[7]][min(weapon6shots,length(weapon6[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
        #2. if you did not use shield to block, damage armor and hull
        #frag is a special case wrt multiplier
        if(unlist(weapon6[2])==0.25){weapon6mult = 4}
        #2.1. damage armor and hull
        hitstrength <- 0
        damage <- 0
        if(mode == GUN) {
          hitstrength <- weapon6[[1]]
          damage <- weapon6[[1]]
        }
        if(mode == BEAM) {
          hitstrength <- weapon6[[1]]/2
          damage <- weapon6[[1]]*weapon6[[3]][[timepoint]]*beam_tick_time
        }
        hulldamage <- damage(armormatrix,weapon6[[8]][[min(weapon6shots,length(weapon6[[8]]))]],startingarmor,minimumarmormultiplier,weapon6[[1]],hitstrength,weapon6mult)
        hullhp <- hullhp - hulldamage
        hullhp <- max(hullhp, 0)
      }
    }
    weapon6shots <- weapon6shots + weapon6[[3]][timepoint]
  }
 
  shots <- weapon7[[3]][[timepoint]]
 
 
  #skip the whole thing if we are not firing
  if (weapon7[[4]] !="" & shots > 0){
    weapon7mult <- weapon7[[2]]
    mode <- weapon7[[6]]
    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
          hardflux <- hardflux + weapon7[[1]]*weapon7mult*weapon7[[7]][min(weapon7shots,length(weapon7[[7]]))]*shieldefficacy
          hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon7[[1]]*weapon7[[3]][[timepoint]]*beam_tick_time*weapon7mult*weapon7[[7]][min(weapon7shots,length(weapon7[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
        #2. if you did not use shield to block, damage armor and hull
        #frag is a special case wrt multiplier
        if(unlist(weapon7[2])==0.25){weapon7mult = 4}
        #2.1. damage armor and hull
        hitstrength <- 0
        damage <- 0
        if(mode == GUN) {
          hitstrength <- weapon7[[1]]
          damage <- weapon7[[1]]
        }
        if(mode == BEAM) {
          hitstrength <- weapon7[[1]]/2
          damage <- weapon7[[1]]*weapon7[[3]][[timepoint]]*beam_tick_time
        }
        hulldamage <- damage(armormatrix,weapon7[[8]][[min(weapon7shots,length(weapon7[[8]]))]],startingarmor,minimumarmormultiplier,weapon7[[1]],hitstrength,weapon7mult)
        hullhp <- hullhp - hulldamage
        hullhp <- max(hullhp, 0)
      }
    }
    weapon7shots <- weapon7shots + weapon7[[3]][timepoint]
  }
 
  shots <- weapon8[[3]][[timepoint]]
 
 
  #skip the whole thing if we are not firing
  if (weapon8[[4]] !="" & shots > 0){
    weapon8mult <- weapon8[[2]]
    mode <- weapon8[[6]]
    if(mode == BEAM) {
      shots <- 1
    }
    for (s in 1:shots){
      #1. use shield to block if you can
      if (test > 0){
        #hard flux
        if(mode == GUN){
          hardflux <- hardflux + weapon8[[1]]*weapon8mult*weapon8[[7]][min(weapon8shots,length(weapon8[[7]]))]*shieldefficacy
          hardflux <- min(hardflux, fluxcap-softflux)
        }
        if(mode == BEAM){
          softflux <- softflux + weapon8[[1]]*weapon8[[3]][[timepoint]]*beam_tick_time*weapon8mult*weapon8[[7]][min(weapon8shots,length(weapon8[[7]]))]*shieldefficacy
          softflux <- min(softflux, fluxcap-hardflux)
        }
        shieldblock <- 1
      } else {
        #2. if you did not use shield to block, damage armor and hull
        #frag is a special case wrt multiplier
        if(unlist(weapon8[2])==0.25){weapon8mult = 4}
        #2.1. damage armor and hull
        hitstrength <- 0
        damage <- 0
        if(mode == GUN) {
          hitstrength <- weapon8[[1]]
          damage <- weapon8[[1]]
        }
        if(mode == BEAM) {
          hitstrength <- weapon8[[1]]/2
          damage <- weapon8[[1]]*weapon8[[3]][[timepoint]]*beam_tick_time
        }
        hulldamage <- damage(armormatrix,weapon8[[8]][[min(weapon8shots,length(weapon8[[8]]))]],startingarmor,minimumarmormultiplier,weapon8[[1]],hitstrength,weapon8mult)
        hullhp <- hullhp - hulldamage
        hullhp <- max(hullhp, 0)
      }
    }
    weapon8shots <- weapon8shots + weapon8[[3]][timepoint]
  }
 
  armorhp <- sum(armormatrix)*15/((ship[[6]]+4)*5)
  if(hullhp==0) armorhp <- 0
 
  if (shieldblock != 0) fluxdissip <- fluxdissip - shieldupkeep
 
  if (softflux > 0){
    if (softflux > fluxdissip) softflux <- softflux - fluxdissip
    else {
      fluxdissip <- max(0,fluxdissip - softflux)
      softflux <- 0
    }
  }
  if (hardflux > 0 & shieldblock == 0){
    hardflux <- max(0,hardflux - fluxdissip)
  }
  if(hullhp > 0){} else {
    softflux <- 0
    hardflux <- 0
  }
  return(list(timepoint, softflux, hardflux, armorhp, hullhp, shieldregen, shieldmax, startingarmor,armormatrix))
}

totaltime <- time_limit

armorhp <- ship[4]
shieldhp <- ship[3]
hullhp <- ship[1]
fluxdissip <- ship[2]
softflux <- 0
hardflux <- 0
fluxcap <- ship[3]
armorhp <- ship[4]
startingarmor <- ship[4]
shieldefficacy <- ship[8]
shieldupkeep <- ship[9]

timeseriesarray <- data.frame(matrix(ncol = 4,nrow=0))

for (t in 1:totaltime){
  state <- timeseries(t,softflux,hardflux,armorhp,hullhp,shieldregen,shieldmax,startingarmor,armormatrix)
  softflux <- state[[2]]
  hardflux <- state[[3]]
  armorhp <- state[[4]]
  hullhp <- state[[5]]
  flux <- softflux + hardflux
  armormatrix <- state[[9]]
  if(hullhp == 0){flux <- 0}
  timeseriesarray <- rbind(timeseriesarray , c(state[[1]], flux/fluxcap*100, hardflux/fluxcap*100, softflux/fluxcap*100, state[[4]]/startingarmor*100, state[[5]]/ship[1]*100))
 
}

colnames(timeseriesarray) <-  c("Time", "Flux", "Hardflux", "Softflux", "Armor", "Hull")
weaponstitle <- paste(unlist(weapon1[4]),unlist(weapon2[4]),unlist(weapon3[4]),unlist(weapon4[4]),unlist(weapon5[4]),unlist(weapon6[4]),unlist(weapon7[4]),unlist(weapon8[4]))


ggplot(timeseriesarray, aes(x=Time))  +
  geom_line(aes(y = Flux, color = "Flux")) +
  geom_line(aes(y = Hardflux, color = "Hardflux")) +
  geom_line(aes(y = Softflux, color = "Softflux")) +
  geom_line(aes(y = Armor, color="Armor")) +
  geom_line(aes(y = Hull, color="Hull")) +
  scale_colour_manual("",
                      breaks = c("Flux", "Softflux", "Hardflux", "Armor", "Hull"),
                      values = c("magenta", "lightsteelblue", "blue", "red", "maroon")) +
  ylab("% max") +
  xlab("Time (s)") +
  labs(title=weaponstitle)


[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 21, 2022, 09:10:24 AM
All right here's the code for weapons optimization. Sorry if it's a little janky, I wrote most of it on the bus again and then during a break. (https://i.ibb.co/4226kkC/approlight-22.png) (https://imgbb.com/) And most of the ship data isn't here.

However, let me tell you, this is blazing fast with the new functions. Like the result is printed instantly when the function is done loading, instead of taking 20 minutes per ship. The speedup must be 100x rather than 10x.

code
Code


#operating modes DO NOT CHANGE CODE WILL BREAK IF GUN IS NOT 0 AND BEAM IS NOT 1 AS THE LITERAL INTEGER VALUE IS USED

GUN <- 0
BEAM <- 1
#ships -  NOT FIXED YET EXCEPT DOMINATOR
#ship, hullhp, flux dissipation, maximum flux, startingarmor, widthinpixels, armorcells, shieldwidth, shieldefficacy, shiedlupkeep, name
#glimmer <- c(1500, 250/0.6, 2500/0.6, 200, 78, 5, 78*2, 0.6, "glimmer")
#brawlerlp <- c(2000, 500/0.8, 3000/0.8, 450,110,floor(110/15), "brawlerlp")
#vanguard <- c(3000, 150, 2000, 600, 104, floor(104/15),"vanguard")
#tempest <- c(1250, 225/0.6, 2500/0.6, 200,64,floor(64/15), "tempest")
#medusa <- c(3000,400/0.6,6000/0.6,300,134,floor(134/15), "medusa")
#hammerhead <- c(5000,250/0.8,4200/0.8,500,108,floor(108/16.4), "hammerhead")
#enforcer <- c(4000,200,4000,900,136,floor(136/15), "enforcer")
dominator <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200, "dominator")
#fulgent <- c(5000,300/0.6,5000/0.6,450, 160, floor(160/15), "fulgent")
#brilliant <- c(8000,600/0.6,10000/0.6,900,160,floor(160/20),"brilliant")
#radiant <- c(20000,1500/0.6,25000/0.6,1500,316,floor(316/30),"radiant")
#onslaught <- c(20000,600,17000,1750,288,floor(288/30),"onslaught")
#aurora <- c(8000,800/0.8,11000/0.8,800,128,floor(128/28), "aurora")
#paragon <- c(18000,1250/0.6,25000/0.6,1500,330,floor(330/30),"paragon")
#conquest <- c(12000,1200/1.4,20000/1.4,1200,190,floor(190/30),"conquest")
#champion <- c(10000,550/0.8,10000/0.8,1250, 180,floor(180/24),"champion")

#ships <- list(glimmer,brawlerlp,vanguard,tempest,medusa,hammerhead,enforcer,dominator,fulgent,brilliant,radiant,onslaught,aurora,paragon,conquest,champion)
ships <- list(dominator)
#engagementrange
range <- 1000

minimumarmormultiplier <- 0.05

#weaponaccuracy - this will be made a function of time and weapon later. the accuracy of a hellbore is 10
acc <- 10

#fudge factor
errorsd <- 0.05
#the fudge factor should be a function of range (more error in position at greater range), but not a function of weapon firing angle, and be expressed in terms of pixels
error <- errorsd*range

#time limit for a single combat
time_limit <- 500



beam_tick <- 1/10


G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))


# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)
hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds))
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds)-1)) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds)-1)) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)-1], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      for (j in 1:(length(upperbounds)-1)) vector[j] <- pnorm(upperbounds[j], mean=0, sd=standard_deviation)
      for (j in 2:(length(upperbounds)-1)) vector[j] <- vector[j] - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)] <- 1-pnorm(upperbounds[length(upperbounds)-1], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#this is not really necessary, just a wrapper for the above new function to fit into the old code
createdistribution <- function(acc,mode){
  return(hit_distribution(anglerangevector,error,acc))
}

# this is the default distribution of damage to armor cells
b <- matrix(0,nrow=5,ncol=5)
b[1:5,2:4] <- 1/30
b[2:4,1:5] <- 1/30
b[2:4,2:4] <- 1/15
b[1,1] <- 0
b[1,5] <- 0
b[5,1] <- 0
b[5,5] <- 0

#this function returns the chance to hit a simple width
hitchance <- function(acc,error,lowerpoint,higherpoint){
  return(hit_distribution(c(lowerpoint,higherpoint,0),error,acc)[[2]])
}
#this function generates a sum of matrices multiplied by the distribution

createhitmatrix <- function(acc){
  hitmatrix <- matrix(0,5,ship[6]+4)
  distributionvector <- createdistribution(acc)
  for (i in 1:ship[6]){
    hitmatrix[,i:(i+4)] <- hitmatrix[,i:(i+4)]+b*(distributionvector[i+1])
  }
  return(hitmatrix)
}

#for weapons with damage changing over time we need a sequence of matrices
createhitmatrixsequence <- function(accvector){
  hitmatrixsequence <- list()
  for (i in 1:length(accvector)){
    hitmatrixsequence[[i]] <- createhitmatrix(accvector[i])
  }
  return(hitmatrixsequence)
}


#this function is absolutely performance critical (run millions of times) so write it in C++ using
#elementary operations
library(Rcpp)
#what this function shall do is:
#1 ) modify the armor matrix in the R global environment by subtracting armor damage
#2 ) return hull damage
#A is the armor matrix
#rows_A is the number of rows in A
#cols_A is the number of columns in A
#D is the damage matrix (note: we do not need a rows_D etc as these matrices must be same size)
#a is starting armor for the whole ship
#a_mod is minimum armor
#d is raw damage from whole weapon shot
#h is hit strength of weapon shot
#m is modifier, 2= kinetic, 1 = energy, 0.5 = he, 4 = frag
#hd is hull damage
#do NOT pass a hit strength of 0 to this function as it does not check for dividing by zero
#overall, this function does no sanity or safety checking so be careful with it
cppFunction('double damage(NumericMatrix A, NumericMatrix D, double a, double a_mod, double d, double h, double m){
  double hd = 0;
  int rows_A = A.nrow();
  int cols_A = A.ncol();
  a = a/(rows_A*cols_A);
  for (int j = 0; j < cols_A; j++){
  for (int i = 0; i < rows_A; i++){
    double armor = std::max(A(i,j), a_mod*a);
    double probmult = D(i,j);
    double adjusted_d = d*h/m/(armor+h/m)*probmult;
    if (adjusted_d <= armor){
      A(i,j) = A(i,j) - adjusted_d;
      A(i,j) = std::max(A(i,j), 0.0);
    }
    if (adjusted_d > armor){
      A(i,j) = 0.0;
      adjusted_d = (adjusted_d - armor)*m;
      hd = hd + adjusted_d;
    }
  }
  }
  return hd;
}
')

#general function to generate ticks
#1. general constants
#the interval of discrete time (time lattice parameter) we are using in the model, in seconds
time_interval <- 1
#how long 1 tick of a beam lasts, in seconds
beam_tick <- 1/10
#minimum interval that exists in the game, in case a modder has somehow specified a lower value for something
global_minimum_time <- 0.05
#operating modes
UNLIMITED <- -1

#times in seconds, ammoregen is in ammo / second
hits <- function(chargeup, chargedown, burstsize, burstdelay, ammo=UNLIMITED, ammoregen=0, reloadsize=0, traveltime=0, mode=GUN){
  #specify sane minimum delays, since the game enforces weapons can only fire once every 0.05 sec
  #for beams, refiring delay is given by burstdelay, for guns it is burstdelay in case burstdelay is > 0 (==0 is shotgun) and chargedown
  if(burstdelay > 0 | mode == BEAM) burstdelay <- max(burstdelay, global_minimum_time)
  if(mode == GUN) chargedown <- max(chargedown, global_minimum_time)
  #this vector will store all the hit time coordinates
  #current time
  #insert a very small fraction here to make time round correctly
  time <- 0.001
  #maximum ammo count is ammo given at start
  maxammo <- ammo
  #this is used to do ammo regeneration, 0 = not regenerating ammo, 1 = regenerating ammo
  regeneratingammo <- 0
  ammoregentimecoordinate <- 0
  ammoregenerated <- 0
 
  #we are firing a gun
  if (mode == GUN) {
    Hits <- vector(mode="double", length = 0)
    while(time < time_limit){
      time <- time + chargeup
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
     
      if (burstdelay == 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            ammo <- ammo - 1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
          }
        }
      }
      if (burstdelay > 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            time <- time + burstdelay
            ammo <- ammo -1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
           
          }
        }
      }
      time <- time+chargedown
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
    }
    timeseries <- vector(mode="integer", length = time_limit/time_interval)
    timeseries[1] <- length(Hits[Hits >= 0 & Hits <= 1*time_interval])
    for (i in 2:time_limit/time_interval) timeseries[i] <- length(Hits[Hits > (i-1)*time_interval & Hits <= i*time_interval])
    return(timeseries)
  }
  #we are firing a beam
  if (mode == BEAM) {
    chargeup_ticks <- chargeup/beam_tick
    chargedown_ticks <- chargedown/beam_tick
    burst_ticks <- burstsize/beam_tick
    #for a beam we will instead use a matrix to store timepoint and beam intensity at timepoint
    beam_matrix <- matrix(nrow=0,ncol=2)
    #burst size 0 <- the beam never stops firing
    if(burstsize == 0){
      for (i in 1:chargeup_ticks) {
        #beam intensity scales quadratically during chargeup, so
      }
      while ( time < time_limit) {
        beam_matrix <- rbind(beam_matrix,c(time, 1))
        time <- time+beam_tick
      }
    } else {
      while (time < time_limit) {
        if (ammo != 0){
          ammo <- ammo - 1
          if (chargeup_ticks > 0){
            for (i in 1:chargeup_ticks) {
              beam_matrix <- rbind(beam_matrix,c(time, (i*beam_tick)^2))
              time <- time+beam_tick
              if (regeneratingammo == 0) {
                ammoregentimecoordinate <- time
                regeneratingammo <- 1
              }
              if(time - ammoregentimecoordinate > 1/ammoregen){
                ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
                ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
                if(ammoregenerated >= reloadsize){
                  ammo <- ammo+ ammoregenerated
                  ammoregenerated <- 0
                }
                if(ammo >= maxammo){
                  ammo <- maxammo
                  regeneratingammo <- 0
                }
              }
            }
          }
          for (i in 1:burst_ticks){
            beam_matrix <- rbind(beam_matrix,c(time, 1))
            time <- time+beam_tick
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
         
          if (chargedown_ticks > 0){
            for (i in 1:chargedown_ticks){
              beam_matrix <- rbind(beam_matrix,c(time, ((chargedown_ticks-i)*beam_tick)^2))
              time <- time+beam_tick
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
          time <- time + burstdelay
          if(time - ammoregentimecoordinate > 1/ammoregen){
            ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
            ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
            if(ammoregenerated >= reloadsize){
              ammo <- ammo+ ammoregenerated
              ammoregenerated <- 0
            }
            if(ammo >= maxammo){
              ammo <- maxammo
              regeneratingammo <- 0
            }
          }
        }
        time <- time + global_minimum_time
        if(time - ammoregentimecoordinate > 1/ammoregen){
          ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
          ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
          if(ammoregenerated >= reloadsize){
            ammo <- ammo+ ammoregenerated
            ammoregenerated <- 0
          }
          if(ammo >= maxammo){
            ammo <- maxammo
            regeneratingammo <- 0
          }
        }
      }
    }
    timeseries <- vector(mode="double", length = time_limit/time_interval)
    for (i in 1:length(timeseries)) {
      timeseries[i] <- sum(beam_matrix[beam_matrix[,1] < i & beam_matrix[,1] > i-1,2])
    }
    return(timeseries)
  }
}

squalltics <- hits(0,10,20,0.5)
locusttics <- hits(0,5,40,0.1)
#special
hurricanetics <- hits(0,15,9,0)
harpoontics <- hits(0,8.25,4,0.25)
sabottics <- hits(0,8.75,2,0.25)
gausstics <- hits(1,1,1,0)
ionbeamtics <- hits(0.1,0.1,0,0,mode=BEAM)

#WEAPON ACCURACY
#missiles do not have spread
squallacc <- c(0)
locustacc <- c(0)
hurricaneacc <- c(0)
harpoonacc <- c(0)
sabotacc <- c(0)

#gauss has a spread of 0 and no increase per shot
gaussacc <- c(0)
#hephaestus has a spread of 0 and it increases by 2 per shot to a max of 10
#hephaestusacc <- c(seq(0,10,2))
#mark ix has a spread of 0 and it increases by 2 per shot to a max of 15
#markixacc <- c(seq(0,15,2),15)
#mjolnir has a spread of 0 and it increases by 1 per shot to a max of 5
#mjolniracc <- c(seq(1,5,1))
#hellbore has a spread of 10
#hellboreacc <- c(10)
#storm needler has a spread of 10
#stormneedleracc <- c(10)
ionbeamacc <- c(0)

#damage per shot, damage type (2=kinetic, 0.5=he, 0.25=frag, 1=energy), tics, weapon name, weapon accuracy over time, hit chance, mode
squall <- list(250, 2, squalltics, "Squall", squallacc, GUN)
locust <- list(200, 0.25, locusttics, "Locust", locustacc, GUN)
hurricane <- list(500, 0.5, hurricanetics, "Hurricane", hurricaneacc, GUN)
harpoon <- list(750, 0.5, harpoontics, "Harpoon", harpoonacc, GUN)
sabot <- list(200, 2, sabottics, "Sabot", sabotacc, GUN)
gauss <- list(700, 2, gausstics, "Gauss", gaussacc, GUN)
#hephaestus <- list(120, 0.5, hephaestustics, "Hephaestus", hephaestusacc)
#markix <- list(200, 2, markixtics, "Mark IX", markixacc)
#mjolnir <- list(400, 1, mjolnirtics, "Mjolnir", mjolniracc)
#hellbore <- list(750, 0.5, hellboretics, "Hellbore", hellboreacc)
#stormneedler <- list(50, 2, stormneedlertics, "Storm Needler", stormneedleracc)

#for beams, damage per second, and then the rest as previously
ionbeam <- list(1000, 1, ionbeamtics, "Super Ion Beam", ionbeamacc, BEAM)
dummy <- list(0,0,c(seq(0,time_limit,1)),"",c(0),c(0),GUN)

#which weapons are we studying?

weapon1choices <- list(squall, locust, hurricane)
weapon2choices <- list(squall, locust, hurricane)
weapon3choices <- list(harpoon, sabot)
weapon4choices <- list(harpoon, sabot)
weapon5choices <- list(ionbeam, gauss)
weapon6choices <- list(ionbeam, gauss)
weapon7choices <- list(ionbeam, gauss)
weapon8choices <- list(ionbeam, gauss)

#how many unique weapon loadouts are there?

#get names of weapons from a choices list x
getweaponnames <- function(x){
  vector <- vector(mode="character")
  for (i in 1:length(x)){
    vector <- cbind(vector, x[[i]][[4]])
  }
  return(vector)
}
#convert the names back to numbers when we are done based on a weapon choices list y
convertweaponnames <- function(x, y){
  vector <- vector(mode="integer")
  for (j in 1:length(x)) {
    for (i in 1:length(y)){
      if(x[j] == y[[i]][[4]]) vector <- cbind(vector, i)
    }
  }
  return(vector)
}

#this section of code generates a table of all unique loadouts that we can create using the weapon choices available
generatepermutations <- 1
if (generatepermutations == 1){
  #enumerate weapon choices as integers
 
  perm1 <- seq(1,length(weapon1choices),1)
  perm2 <- seq(1,length(weapon2choices),1)
  perm3 <- seq(1,length(weapon3choices),1)
  perm4 <- seq(1,length(weapon4choices),1)
  perm5 <- seq(1,length(weapon5choices),1)
  perm6 <- seq(1,length(weapon6choices),1)
  perm7 <- seq(1,length(weapon7choices),1)
  perm8 <- seq(1,length(weapon8choices),1)
 
  #create a matrix of all combinations
  perm1x2 <- expand.grid(perm1,perm2)
  #sort, then only keep unique rows
  perm1x2 <- unique(t(apply(perm1x2, 1, sort)))
 
  perm3x4 <- expand.grid(perm3,perm4)
  perm3x4 <- unique(t(apply(perm3x4, 1, sort)))
 
  perm5x6 <- expand.grid(perm5,perm6)
  perm5x6 <- unique(t(apply(perm5x6, 1, sort)))
 
  perm7x8 <- expand.grid(perm7,perm8)
  perm7x8 <- unique(t(apply(perm7x8, 1, sort)))
 
  #now that we have all unique combinations of all two weapons, create a matrix containing all combinations of these unique combinations
  allperms <- matrix(0,0,(length(perm1x2[1,])+length(perm3x4[1,])+length(perm5x6[1,])+length(perm7x8[1,])))
  for(i in 1:length(perm1x2[,1])) for(j in 1:length(perm3x4[,1])) for(k in 1:length(perm5x6[,1])) for(l in 1:length(perm7x8[,1])) allperms <- rbind(allperms, c(perm1x2[i,],perm3x4[j,],perm5x6[k,],perm7x8[l,])
  )
  #this is just for testing, can remove
  allperms
  #we save this so we don't have to compute it again
  saveRDS(allperms, file="allperms.RData")
 
} else {
  allperms <- readRDS("allperms.RData")
}

#now compute a main lookuptable to save on computing time
#the lookuptable should be a list of lists, so that
#lookuptable[[ship]][[weapon]][[1]] returns hit chance vector and
#lookuptable[[ship]][[weapon]][[2]] returns hit probability matrix
#time for some black R magic

#note: the lookuptable will be formulated such that there is a running index of weapons rather than sub-lists, so all weapons will be indexed consecutively so we have lookuptable [[1]][[1]] = [[ship1]][[weaponchoices1_choice1]], etc. So that is what the below section does.

#read or generate lookuptable
generatelookuptable <- 1
if(generatelookuptable == 1){
 
  lookuptable <- list()
 
  for (f in 1:length(ships)){
    lookuptable[[f]] <- list()
    ship <- ships[[f]]
    ship <- as.double(ship[1:9])
    #how much is the visual arc of the ship in rad?
    shipangle <- ship[5]/(2* pi *range)
   
    #how much is the visual arc of a single cell of armor in rad?
    cellangle <- shipangle/ship[6]
   
    #now assume the weapon is targeting the center of the ship's visual arc and that the ship is in the center of the weapon's firing arc
    #which cell will the shot hit, or will it miss?
    #call the cells (MISS, cell1, cell2, ... ,celli, MISS) and get a vector giving the (maximum for negative / minimum for positive) angles for hitting each
    anglerangevector <- vector(mode="double", length = ship[6]+1)
    anglerangevector[1] <- -shipangle/2
    for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle
    #now convert it to pixels
    anglerangevector <- anglerangevector*2*pi*range
   
    weaponindexmax <- length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+length(weapon8choices)
   
    for (x in 1:weaponindexmax) {
      print(x)
      if(x <= length(weapon1choices)){
        weapon1<-weapon1choices[[x]]
        if(weapon1[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon1[[5]]))
          for (i in 1:length(weapon1[[5]])){
            hitchancevector[i] <- hitchance(weapon1[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon1[[5]])
        }
      }
      if((x > length(weapon1choices)) &  (x <= length(weapon1choices) + length(weapon2choices))){
        weapon2<-weapon2choices[[x-length(weapon1choices)]]
        if(weapon2[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon2[[5]]))
          for (i in 1:length(weapon2[[5]])){
            hitchancevector[i] <- hitchance(weapon2[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon2[[5]])
        }
      }
     
      if((x > length(weapon1choices) + length(weapon2choices)) &  (x <= length(weapon2choices) + length(weapon1choices) + length(weapon3choices))){
        weapon3<-weapon3choices[[x-length(weapon2choices)-length(weapon1choices)]]
        if(weapon3[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon3[[5]]))
          for (i in 1:length(weapon3[[5]])){
            hitchancevector[i] <- hitchance(weapon3[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon3[[5]])
        }
      } 
     
      if((x > length(weapon2choices) + length(weapon1choices) + length(weapon3choices)) &  (x <= length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon4choices))){
        weapon4<-weapon4choices[[x-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon4[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon4[[5]]))
          for (i in 1:length(weapon4[[5]])){
            hitchancevector[i] <- hitchance(weapon4[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon4[[5]])
        }
      }
      if((x > length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon4choices)) &  (x <= length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon5choices))){
        weapon5<-weapon5choices[[x-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon5[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon5[[5]]))
          for (i in 1:length(weapon5[[5]])){
            hitchancevector[i] <- hitchance(weapon5[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon5[[5]])
        }
      }
      if((x > length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon5choices)) &  (x <= length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon6choices))){
        weapon6<-weapon6choices[[x-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon6[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon6[[5]]))
          for (i in 1:length(weapon6[[5]])){
            hitchancevector[i] <- hitchance(weapon6[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon6[[5]])
        }
      }
      if((x > length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon6choices)) &  (x <= length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon7choices))){
        weapon7<-weapon7choices[[x-length(weapon6choices)-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon7[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon7[[5]]))
          for (i in 1:length(weapon7[[5]])){
            hitchancevector[i] <- hitchance(weapon7[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon7[[5]])
        }
      }
      if((x > length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon7choices)) &  (x <= length(weapon7choices) + length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon8choices))){
        weapon8<-weapon8choices[[x-length(weapon7choices)-length(weapon6choices)-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon8[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon8[[5]]))
          for (i in 1:length(weapon8[[5]])){
            hitchancevector[i] <- hitchance(weapon8[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon8[[5]])
        }
      }
     
    }
  }
  # save this so we don't have to re-compute it
  saveRDS(lookuptable, file="lookuptable.RData")
} else {
  lookuptable <- readRDS("lookuptable.RData")
}

lookup <- function(ship, weapon, var) return(lookuptable[[ship]][[weapon]][[var]])

#go through all ships
for (f in 1:length(ships)){
 
  ship <- ships[[f]]
  #format ship data types appropriately
  shipname <- ship[[10]]
  ship <- as.double(ship[1:9])
 
 
 
 
 
 
 
  timeseriesarray <- data.frame(matrix(ncol = 7,nrow=0))
 
 
 
  timetokill=0
 
 
  shieldblock <- 0
 

 
  timeseries <- function(timepoint, softflux, hardflux, armorhp, hullhp, fluxdissip, fluxcap, startingarmor,armormatrix){
    weaponacc <- 0

    #are we using shield to block?
    shieldblock <- 0
    hulldamage <- 0
   
   
    #weapon 1
    weapon1mult <- weapon1[[2]]
    weapon2mult <- weapon2[[2]]
    weapon3mult <- weapon3[[2]]
    weapon4mult <- weapon4[[2]]
    weapon5mult <- weapon5[[2]]
    weapon6mult <- weapon6[[2]]
    weapon7mult <- weapon7[[2]]
    weapon8mult <- weapon8[[2]]
   
    shots <- weapon1[[3]][[timepoint]]
    #here we must convert beam ticks to fractional shots
    shots1 <- weapon1[[3]][[timepoint]]*beam_tick^(weapon1[[6]])
    shots2 <- weapon2[[3]][[timepoint]]*beam_tick^(weapon2[[6]])
    shots3 <- weapon3[[3]][[timepoint]]*beam_tick^(weapon3[[6]])
    shots4 <- weapon4[[3]][[timepoint]]*beam_tick^(weapon4[[6]])
    shots5 <- weapon5[[3]][[timepoint]]*beam_tick^(weapon5[[6]])
    shots6 <- weapon6[[3]][[timepoint]]*beam_tick^(weapon6[[6]])
    shots7 <- weapon7[[3]][[timepoint]]*beam_tick^(weapon7[[6]])
    shots8 <- weapon8[[3]][[timepoint]]*beam_tick^(weapon8[[6]])
    #test is used to determine if we are firing or blocking this turn
    test <- (fluxcap-softflux-hardflux - weapon1[[1]]*weapon1mult*shots1 - weapon2[[1]]*weapon2mult*shots2 - weapon3[[1]]*weapon3mult*shots3 - weapon4[[1]]*weapon4mult*shots4 - weapon5[[1]]*weapon5mult*shots5 - weapon6[[1]]*weapon6mult*shots6 - weapon7[[1]]*weapon7mult*shots7 - weapon8[[1]]*weapon8mult*shots8)   
    #skip the whole thing if we are not firing
    if (weapon1[[4]] !="" & shots > 0){
      mode <- weapon1[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon1[[1]]*weapon1mult*weapon1[[7]][min(weapon1shots,length(weapon1[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon1[[1]]*weapon1[[3]][[timepoint]]*beam_tick*weapon1mult*weapon1[[7]][min(weapon1shots,length(weapon1[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon1[2])==0.25){weapon1mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon1[[1]]
            damage <- weapon1[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon1[[1]]/2
            damage <- weapon1[[1]]*weapon1[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon1[[8]][[min(weapon1shots,length(weapon1[[8]]))]],startingarmor,minimumarmormultiplier,damage,hitstrength,weapon1mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon1shots <- weapon1shots + weapon1[[3]][timepoint]
    }
   
    #repeat for other weapons
    shots <- weapon2[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon2[[4]] !="" & shots > 0){
      weapon2mult <- weapon2[[2]]
      mode <- weapon2[[6]]
     
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon2[[1]]*weapon2mult*weapon2[[7]][min(weapon2shots,length(weapon2[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon2[[1]]*weapon2[[3]][[timepoint]]*beam_tick*weapon2mult*weapon2[[7]][min(weapon2shots,length(weapon2[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon2[2])==0.25){weapon2mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon2[[1]]
            damage <- weapon2[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon2[[1]]/2
            damage <- weapon2[[1]]*weapon2[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon2[[8]][[min(weapon2shots,length(weapon2[[8]]))]],startingarmor,minimumarmormultiplier,weapon2[[1]],hitstrength,weapon2mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon2shots <- weapon2shots + weapon2[[3]][timepoint]
    }
   
   
    shots <- weapon3[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon3[[4]] !="" & shots > 0){
      weapon3mult <- weapon3[[2]]
      mode <- weapon3[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon3[[1]]*weapon3mult*weapon3[[7]][min(weapon3shots,length(weapon3[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon3[[1]]*weapon3[[3]][[timepoint]]*beam_tick*weapon3mult*weapon3[[7]][min(weapon3shots,length(weapon3[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon3[2])==0.25){weapon3mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon3[[1]]
            damage <- weapon3[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon3[[1]]/2
            damage <- weapon3[[1]]*weapon3[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon3[[8]][[min(weapon3shots,length(weapon3[[8]]))]],startingarmor,minimumarmormultiplier,weapon3[[1]],hitstrength,weapon3mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon3shots <- weapon3shots + weapon3[[3]][timepoint]
    }
   
   
    shots <- weapon4[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon4[[4]] !="" & shots > 0){
      weapon4mult <- weapon4[[2]]
      mode <- weapon4[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon4[[1]]*weapon4mult*weapon4[[7]][min(weapon4shots,length(weapon4[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon4[[1]]*weapon4[[3]][[timepoint]]*beam_tick*weapon4mult*weapon4[[7]][min(weapon4shots,length(weapon4[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon4[2])==0.25){weapon4mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon4[[1]]
            damage <- weapon4[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon4[[1]]/2
            damage <- weapon4[[1]]*weapon4[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon4[[8]][[min(weapon4shots,length(weapon4[[8]]))]],startingarmor,minimumarmormultiplier,weapon4[[1]],hitstrength,weapon4mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon4shots <- weapon4shots + weapon4[[3]][timepoint]
    }
   
   
    shots <- weapon5[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon5[[4]] !="" & shots > 0){
      weapon5mult <- weapon5[[2]]
      mode <- weapon5[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon5[[1]]*weapon5mult*weapon5[[7]][min(weapon5shots,length(weapon5[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon5[[1]]*weapon5[[3]][[timepoint]]*beam_tick*weapon5mult*weapon5[[7]][min(weapon5shots,length(weapon5[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon5[2])==0.25){weapon5mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon5[[1]]
            damage <- weapon5[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon5[[1]]/2
            damage <- weapon5[[1]]*weapon5[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon5[[8]][[min(weapon5shots,length(weapon5[[8]]))]],startingarmor,minimumarmormultiplier,weapon5[[1]],hitstrength,weapon5mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon5shots <- weapon5shots + weapon5[[3]][timepoint]
    }
   
    shots <- weapon6[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon6[[4]] !="" & shots > 0){
      weapon6mult <- weapon6[[2]]
      mode <- weapon6[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon6[[1]]*weapon6mult*weapon6[[7]][min(weapon6shots,length(weapon6[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon6[[1]]*weapon6[[3]][[timepoint]]*beam_tick*weapon6mult*weapon6[[7]][min(weapon6shots,length(weapon6[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon6[2])==0.25){weapon6mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon6[[1]]
            damage <- weapon6[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon6[[1]]/2
            damage <- weapon6[[1]]*weapon6[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon6[[8]][[min(weapon6shots,length(weapon6[[8]]))]],startingarmor,minimumarmormultiplier,weapon6[[1]],hitstrength,weapon6mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon6shots <- weapon6shots + weapon6[[3]][timepoint]
    }
   
    shots <- weapon7[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon7[[4]] !="" & shots > 0){
      weapon7mult <- weapon7[[2]]
      mode <- weapon7[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon7[[1]]*weapon7mult*weapon7[[7]][min(weapon7shots,length(weapon7[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon7[[1]]*weapon7[[3]][[timepoint]]*beam_tick*weapon7mult*weapon7[[7]][min(weapon7shots,length(weapon7[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon7[2])==0.25){weapon7mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon7[[1]]
            damage <- weapon7[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon7[[1]]/2
            damage <- weapon7[[1]]*weapon7[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon7[[8]][[min(weapon7shots,length(weapon7[[8]]))]],startingarmor,minimumarmormultiplier,weapon7[[1]],hitstrength,weapon7mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon7shots <- weapon7shots + weapon7[[3]][timepoint]
    }
   
    shots <- weapon8[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon8[[4]] !="" & shots > 0){
      weapon8mult <- weapon8[[2]]
      mode <- weapon8[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon8[[1]]*weapon8mult*weapon8[[7]][min(weapon8shots,length(weapon8[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon8[[1]]*weapon8[[3]][[timepoint]]*beam_tick*weapon8mult*weapon8[[7]][min(weapon8shots,length(weapon8[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon8[2])==0.25){weapon8mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon8[[1]]
            damage <- weapon8[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon8[[1]]/2
            damage <- weapon8[[1]]*weapon8[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon8[[8]][[min(weapon8shots,length(weapon8[[8]]))]],startingarmor,minimumarmormultiplier,weapon8[[1]],hitstrength,weapon8mult)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon8shots <- weapon8shots + weapon8[[3]][timepoint]
    }
   
    armorhp <- sum(armormatrix)*15/((ship[[6]]+4)*5)
    if(hullhp==0) armorhp <- 0
   
    if (shieldblock != 0) fluxdissip <- fluxdissip - shieldupkeep
   
    if (softflux > 0){
      if (softflux > fluxdissip) softflux <- softflux - fluxdissip
      else {
        fluxdissip <- max(0,fluxdissip - softflux)
        softflux <- 0
      }
    }
    if (hardflux > 0 & shieldblock == 0){
      hardflux <- max(0,hardflux - fluxdissip)
    }
    if(hullhp > 0){} else {
      softflux <- 0
      hardflux <- 0
    }
    return(list(timepoint, softflux, hardflux, armorhp, hullhp, fluxdissip, fluxcap, startingarmor,armormatrix))
  }
 
  totaltime = 500
 
 
 
  armorhp <- ship[4]
  shieldhp <- ship[3]
  hullhp <- ship[1]
  fluxdissip <- ship[2]
  softflux <- 0
  hardflux <- 0
  fluxcap <- ship[3]
  armorhp <- ship[4]
  startingarmor <- ship[4]
  shieldefficacy <- ship[8]
  shieldupkeep <- ship[9]
 
  weapon1shots <- 1
  weapon2shots <- 1
  weapon3shots <- 1
  weapon4shots <- 1
  weapon5shots <- 1
  weapon6shots <- 1
  weapon7shots <- 1
  weapon8shots <- 1
 
  armormatrix <- matrix(ship[4]/15,5,ship[6]+4)
 
  #now what we do here is we go through all the permutations using the running index, which is i+j+k+l+m+n+o+p for weapons 8
  for (z in 1:length(allperms[,1])) {
    i <- allperms[z,1]
    j <- allperms[z,2]
    k <- allperms[z,3]
    l <- allperms[z,4]
    m <- allperms[z,5]
    n <- allperms[z,6]
    o <- allperms[z,7]
    p <- allperms[z,8]
   
    #for (i in 1:length(weapon1choices)) {
    weapon1<-weapon1choices[[i]]
    #  for (j in 1:length(weapon2choices)) {
    weapon2<-weapon2choices[[j]]
    #    for (k in 1:length(weapon3choices)) {
    weapon3<-weapon3choices[[k]]
    #      for (l in 1:length(weapon4choices)) {
    weapon4<-weapon4choices[[l]]
    #        for (m in 1:length(weapon5choices)) {
    weapon5<-weapon5choices[[m]]
    #          for (n in 1:length(weapon6choices)) {
    weapon6<-weapon6choices[[n]]
    #            for (o in 1:length(weapon7choices)) {
    weapon7<-weapon7choices[[o]]
    #              for (p in 1:length(weapon8choices)) {
    weapon8<-weapon8choices[[p]]
    #lookup <- function(ship, weapon, var) return(lookuptable[[ship]][[weapon]][[var]])
    if(weapon1[4] != ""){
      weapon1[[7]] <- lookup(f,i,1)
      weapon1[[8]] <- lookup(f,i,2)
    }
   
    if(weapon2[4] != ""){
      weapon2[[7]] <- lookup(f,length(weapon1choices)+j,1)
      weapon2[[8]] <- lookup(f,length(weapon1choices)+j,2)
    }
   
    if(weapon3[4] != ""){
      weapon3[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+k,1)
      weapon3[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+k,2)
    }
   
    if(weapon4[4] != ""){
      weapon4[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+l,1)
      weapon4[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+l,2)
    }
   
    if(weapon5[4] != ""){
      weapon5[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+m,1)
      weapon5[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+m,2)
    }
   
    if(weapon6[4] != ""){
      weapon6[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+n,1)
      weapon6[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+n,2)
    }
    if(weapon7[4] != ""){
      weapon7[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+o,1)
      weapon7[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+o,2)
    }
    if(weapon8[4] != ""){
      weapon8[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+p,1)
      weapon8[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+p,2)
    }
   
    #time series - run time series at point t, save it to state, update values according to state, re-run time series, break if ship dies
    for (t in 1:totaltime){
      state <- timeseries(t,softflux,hardflux,armorhp,hullhp,fluxdissip,fluxcap,startingarmor,armormatrix)
      softflux <- state[[2]]
      hardflux <- state[[3]]
      armorhp <- state[[4]]
      hullhp <- state[[5]]
      flux <- softflux + hardflux
      armormatrix <- state[[9]]
      if(hullhp == 0){flux <- 0
      if (timetokill == 0){timetokill <- t
      break}
      }
     
    }
    if (timetokill ==0){timetokill <- NA}
   
    tobind <- c(timetokill,unlist(weapon1[4]),unlist(weapon2[4]),unlist(weapon3[4]),unlist(weapon4[4]),unlist(weapon5[4]),unlist(weapon6[4]),unlist(weapon7[4]),unlist(weapon8[4]))
    timeseriesarray <- rbind(timeseriesarray,tobind)
   
    armorhp <- ship[4]
    shieldhp <- ship[3]
    hullhp <- ship[1]
    fluxdissip <- ship[2]
    softflux <- 0
    hardflux <- 0
    fluxcap <- ship[3]
    armorhp <- ship[4]
    startingarmor <- ship[4]
    shieldefficacy <- ship[8]
    shieldupkeep <- ship[9]
   
    weapon1shots <- 1
    weapon2shots <- 1
    weapon3shots <- 1
    weapon4shots <- 1
    weapon5shots <- 1
    weapon6shots <- 1
    weapon7shots <- 1
    weapon8shots <- 1
    armormatrix <- matrix(ship[4]/15,5,ship[6]+4)
    timetokill <- 0
    #          }
    #        }
    #      }
    #    }
    #  }
    #}
    # }
  }
  #}
  colnames(timeseriesarray) <-  c("Timetokill", "Weapon1", "Weapon2", "Weapon3", "Weapon4", "Weapon5", "Weapon6", "Weapon7", "Weapon8")
 
  sortbytime <- timeseriesarray[order(as.integer(timeseriesarray$Timetokill)),]
 
  write.table(sortbytime, file = paste("optimizeweaponsbytime",shipname,"allweaponswithacc.txt", sep=""), row.names=FALSE, sep="\t")
}
[close]

Output
Spoiler

"Timetokill"   "Weapon1"   "Weapon2"   "Weapon3"   "Weapon4"   "Weapon5"   "Weapon6"   "Weapon7"   "Weapon8"
"4"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"4"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Locust"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Locust"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"4"   "Locust"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Locust"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Locust"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"4"   "Locust"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"4"   "Locust"   "Hurricane"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"5"   "Squall"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"5"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"5"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"5"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"5"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"5"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"5"   "Locust"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"5"   "Hurricane"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"6"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"6"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"6"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"6"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Squall"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"6"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"6"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"6"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"6"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"6"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"6"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Locust"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"6"   "Locust"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"6"   "Locust"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Locust"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"6"   "Locust"   "Locust"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Hurricane"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"6"   "Hurricane"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Hurricane"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"6"   "Hurricane"   "Hurricane"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"7"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"7"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"7"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"7"   "Squall"   "Hurricane"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"
"7"   "Squall"   "Hurricane"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Squall"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"8"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Squall"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Locust"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Locust"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Gauss"   "Gauss"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Hurricane"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Squall"   "Hurricane"   "Sabot"   "Sabot"   "Super Ion Beam"   "Super Ion Beam"   "Gauss"   "Gauss"
"8"   "Squall"   "Hurricane"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Squall"   "Hurricane"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Squall"   "Hurricane"   "Sabot"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Super Ion Beam"
"8"   "Locust"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   "Gauss"   "Gauss"
"8"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"8"   "Locust"   "Locust"   "Harpoon"   "Sabot"   "Gauss"   "Gauss"   "Super Ion Beam"   "Gauss"
"8"   "Locust"   "Locust"   "Sabot"   "Sabot"   "Super Ion Beam"   "Gauss"   "Gauss"   "Gauss"
"
[close]

This code already has the support for studying particular loadouts btw, as we are already doing combinations listed in the allperms variable rather than all permutations.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 21, 2022, 09:32:01 AM
All right here's the code for weapons optimization. Sorry if it's a little janky, I wrote most of it on the bus again and then during a break. (https://i.ibb.co/4226kkC/approlight-22.png) (https://imgbb.com/) And most of the ship data isn't here.

However, let me tell you, this is blazing fast with the new functions. Like the result is printed instantly when the function is done loading, instead of taking 20 minutes per ship. The speedup must be 100x rather than 10x.

Fantastic!  If the R code is done and polished to your satisfaction, then please tell me so I can convert it to Python.  I cannot compile the inline C++ code to call from Python because the NumericMatrix within is from RCpp rather than C++ itself.  Also, I will organize the project should have several parts, each with one responsibility, and want to know if you find any responsibility to be missing.

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 21, 2022, 09:56:35 AM
The R code should be good enough, I don't think that it's necessarily worth polishing since it won't be the final product. Even if there's a hidden error in some calculation the overall structure should be correct and it appears to produce reasonable results. I think if there are such errors we'll catch them in translation - let me know if you find anything suspicious and I'll comment. Changing to C++ for the most intensively used loops seems to produce a huge performance boost so it's probably worth trying to replicate in Python.

That list seems overall correct. You could consider having the combat simulation as its own file as it's shared between several functions.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 21, 2022, 10:06:59 AM
Are there plans to support arbitrary numbers and types of weapons rather than exactly 8 (hard coded)? Feels like that whole aspect of the code could be handled much more cleanly with loops or functions.

Also, are you planning on using an actual optimization algorithm, or are you still just brute force searching all possibilities?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 21, 2022, 10:17:46 AM
I didn't come up with how to do it in R at the time was the main issue. I suppose in retrospect there could have been a list of lists of weapons to which weapons are added, and then you loop through that list, and use the length of that list for outputs, instead of hardcoded weapons.

Please improve the code as you see fit! I have no illusions of being (or plans to become) a competent programmer. It's just a tool for math and science for me.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 21, 2022, 11:13:59 AM
The R code should be good enough, I don't think that it's necessarily worth polishing since it won't be the final product. Even if there's a hidden error in some calculation the overall structure should be correct and it appears to produce reasonable results. I think if there are such errors we'll catch them in translation - let me know if you find anything suspicious and I'll comment.

I just hope that catching errors won't entail re-translating R code.  :(

Quote
Changing to C++ for the most intensively used loops seems to produce a huge performance boost so it's probably worth trying to replicate in Python.

I don't need the RCpp code replicated in Python but rather written in C++ itself, to compile with a C++ compiler into a module for Python to call.

Quote
That list seems overall correct. You could consider having the combat simulation as its own file as it's shared between several functions.

Great idea!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 21, 2022, 11:43:15 AM
Writing it in pure C++ shouldn't be too bad. But is there a way to access global variables from the Python environment within the C++ code? Ie must a way be devised to pass the matrices to the C++ code and then back to Python, or can the C++ code access them from the Python environment somehow, like with NumericMatrix here?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 21, 2022, 12:52:11 PM
Writing it in pure C++ shouldn't be too bad. But is there a way to access global variables from the Python environment within the C++ code? Ie must a way be devised to pass the matrices to the C++ code and then back to Python, or can the C++ code access them from the Python environment somehow, like with NumericMatrix here?

Python objects can be passed to C/C++ functions called through the Python C/C++ API but must be converted to C/C++ variables for C/C++ to work on them.  The nearest Python equivalent to NumericMatrix seems to be nested lists, and the nearest C/C++ equivalent to them seems to be vectors.  The next problem is that the damage function must return those vectors alongside the hull damage because the Python C/C++ API passes by value rather than reference.  We don't want to waste time building Python objects just to hold numbers.  Could we port more of this code into C++ and return the result?

Here's the C++ code reformatted to use only C++ objects and with descriptive variable names.  Also, I do not understand why the damage modifier for FRAGMENTATION is 4 rather than 0.25 if the modifier is meant for shields as the modifiers for KINETIC and HIGH_EXPLOSIVE imply.  Also, what is the raw damage, and how does it compare to the per shot damage, and why are they multiplied together?   The minimum armor calculation confuses me because I'm not sure the armor is evenly distributed but rather is ~1/15th of the armor rainng in each cell.  Note that I have added an & to armor_grid and hit_probability_distribution in the arguments, thereby passing them by reference rather than by value, letting this function mutate armor grid and saving effort.

Code
#include <stdio.h>

#include <vector>

using namespace std;

static double apply_damage(
    vector<vector<double>> &armor_grid,
    vector<vector<double>> &hit_probability_distribution,
    double armor_rating,
    double minimum_armor_for_damage_reduction,
    double damage_per_shot,
    double shield_damage_modifier
)
{
    double hull_damage = 0;
    double armor_damage_per_shot = damage_per_shot / shield_damage_modifier;
   
    for (int j = 0; j < sizeof(armor_grid[0]); j++)
    {
        for (int i = 0; i < sizeof(armor_grid); i++)
        {
            double probable_armor_damage =
                armor_damage_per_shot
                / (max(armor_grid[i][j], minimum_armor_for_damage_reduction)
                   + armor_damage_per_shot)
                * hit_probability_distribution[i][j];
           
            armor_grid[i][j] = max(armor_grid[i][j]
                                   - probable_armor_damage, 0.0);
            hull_damage += max(probable_armor_damage - armor_grid[i][j], 0.0)
                           * shield_damage_modifier;
        }
    }

    return hull_damage;
}
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on November 21, 2022, 04:10:44 PM
If you are writing in Python and the actual math is done using matrices, then you should be able to get nearly all of the speed of C/Fortran by using the Numpy library to do the matrix computations. Numpy has a true array object (set size/data type, contiguous memory, etc) implemented in lower level languages and already wrapped, so you don't need to do that yourself. Depending on the actual operations being done you can even call the BLAS/LAPACK Fortran functions directly (though this is a bit of a pain as you need to wade through the obscure naming conventions of the functions from the 70's).

Also, update to Python 3.11 if you haven't yet: it offers a pretty decent performance upgrade over past versions!

[Edit] Reading the above code, because your matrix operations are element-wise, with Numpy you can do the whole operation in 3 lines with no if statements/loops: 1 line to do the whole armor grid modification, 1 line to sum up the hull damage , 1 line to reset negative armor sections to 0. All looping is handled by numpy's element-wise coding (low level language), the functions being called are Sum, Numpy.maximum (elementwise maximum of an array, IE doing it for the individual components), and arithmetic.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 21, 2022, 04:47:31 PM
If you are writing in Python and the actual math is done using matrices, then you should be able to get nearly all of the speed of C/Fortran by using the Numpy library to do the matrix computations. Numpy has a true array object (set size/data type, contiguous memory, etc) implemented in lower level languages and already wrapped, so you don't need to do that yourself. Depending on the actual operations being done you can even call the BLAS/LAPACK Fortran functions directly (though this is a bit of a pain as you need to wade through the obscure naming conventions of the functions from the 70's).

Also, update to Python 3.11 if you haven't yet: it offers a pretty decent performance upgrade over past versions!

[Edit] Reading the above code, because your matrix operations are element-wise, with Numpy you can do the whole operation in 3 lines with no if statements/loops: 1 line to do the whole armor grid modification, 1 line to sum up the hull damage , 1 line to reset negative armor sections to 0. All looping is handled by numpy's element-wise coding (low level language), the functions being called are Sum, Max (possibly the Numpy variants of those to work on their data structure), and arithmetic, so all of that should be low level as well.

Yes, that would work!  No more C++ awfulness!  Hooray!  Here's what I ended up with while refactoring the code because I had noticed it seemed to be applying one 'operation' across a matrix and could therefore be structured accordingly.  I remain proud of it even though it's useless now because I taught myself a little about how to use pointers in C++ to do such a neat trick as passing an element of a 2D vector as an argument.

Code
#include <stdio.h>

#include <vector>

using namespace std;

static double damage_armor_cell(
    double* armor_cell,
    double* hit_probability,
    double maximum_armor_damage_per_shot,
    double armor_for_damage_reduction,
    double shield_damage_modifier
)
{
    double probable_armor_damage = maximum_armor_damage_per_shot
                                    / (armor_for_damage_reduction
                                        + maximum_armor_damage_per_shot)
                                    * *hit_probability;
    *armor_cell = max(*armor_cell - probable_armor_damage, 0.0);
    return max(probable_armor_damage - *armor_cell, 0.0)
            * shield_damage_modifier;
}

static double apply_damage(
    vector<vector<double>> &armor_grid,
    vector<vector<double>> &hit_probability_distribution,
    double armor_rating,
    double minimum_armor_for_damage_reduction,
    double damage_per_shot,
    double shield_damage_modifier
)
{
    double hull_damage = 0;
    double maximum_armor_damage_per_shot = damage_per_shot
                                            / shield_damage_modifier;
   
    for (int j = 0; j < sizeof(armor_grid[0]); j++)
    {
        for (int i = 0; i < sizeof(armor_grid); i++)
        {
            hull_damage += damage_armor_cell(
                &armor_grid[i][j],
                &hit_probability_distribution[i][j],
                maximum_armor_damage_per_shot,
                minimum_armor_for_damage_reduction,
                shield_damage_modifier
            );
        }
    }

    return hull_damage;
}
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 21, 2022, 09:37:44 PM
Here's the C++ code reformatted to use only C++ objects and with descriptive variable names.  Also, I do not understand why the damage modifier for FRAGMENTATION is 4 rather than 0.25 if the modifier is meant for shields as the modifiers for KINETIC and HIGH_EXPLOSIVE imply.  Also, what is the raw damage, and how does it compare to the per shot damage, and why are they multiplied together?   The minimum armor calculation confuses me because I'm not sure the armor is evenly distributed but rather is ~1/15th of the armor rainng in each cell.  Note that I have added an & to armor_grid and hit_probability_distribution in the arguments, thereby passing them by reference rather than by value, letting this function mutate armor grid and saving effort.

You are correct on the minimum armor part. We can easily solve what starting armor a_cell should be, since we know cells have equal armor and 9*a_cell+12*a_cell/2=a_ship -> a_cell=1/15 a_ship. This is even computed correctly elsewhere in the code (    armormatrix <- matrix(ship[4]/15,5,ship[6]+4)), for some reason my brain just short-circuited here. Thanks for finding the error. The effect was to lower the minimum damage reduction for armor by a great deal, which is why fixing this error leads to more correct TTK numbers (the code now produces
""7"   "Squall"   "Hurricane"   "Harpoon"   "Harpoon"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam"   "Super Ion Beam""

as the fastest TTK).

I'm going to comment the corrected original version of the code since it sounds like you'll be re-implementing it with Numpy (that library might be a good reason to learn Python eventually it sounds like).
Code
/* NumericMatrix leads to the code modifying the matrix in the R global environment on lines 10 to 17. If this is not possible in Python, then the function should return the damaged matrix. The function should also return a double containing the hull damage that goes through. */
double damage(NumericMatrix A, NumericMatrix D, double a, double a_mod, double d, double h, double m){
  double hd = 0;
  int rows_A = A.nrow();
  int cols_A = A.ncol();
/* see above - we were passed the starting armor of the ship, and now we calculate the starting armor of an individual cell */
  a = a/15;
/* go through the armor matrix */
  for (int j = 0; j < cols_A; j++){
  for (int i = 0; i < rows_A; i++){
/* "armor" is the value we will use for the hit strength calculation. IF the armor matrix hp at (i,j) is greater than minimum armor (e.g a_mod=0.05, a=1500/15 (standard dominator) -> minimum armor is 0.05*100) THEN use armor matrix hp ELSE use the minimum armor value for the hit strength calculation */
    double armor = std::max(A(i,j), a_mod*a);
/* probmult refers to the fraction of damage we are assigning to the armor cell according to the probability distribution and it is given by the overall damage distribution matrix D (ie. the one stored in the lookup table, the one we calculate using the normal and uniform distributions) at (i,j) */
    double probmult = D(i,j);
/* adjusted damage is: damage assigned to cell (d = total damage times probmult = fraction of total damage assigned to cell), adjusted by hit strength / (armor + hit strength). In addition, because the modifiers are he = 0.5, kinetic = 2, frag = 4, then the correct adjustment for hit strength to hit armor is hit strength / modifier*/
    double adjusted_d = d*h/m/(armor+h/m)*probmult;
/* if we are dealing less damage than either armor hp or minimum armor then we are not dealing hull damage */
    if (adjusted_d <= armor){
/* reduce armor hp by adjusted allocated damage */
      A(i,j) = A(i,j) - adjusted_d;
/* if armor went below 0 set armor to 0.0 since it is a double */
      A(i,j) = std::max(A(i,j), 0.0);
    }
/* if we are dealing more damage than either armor hp or minimum armor */
    if (adjusted_d > armor){
/* set armor cell hp to 0 */
      A(i,j) = 0.0;
/* now calculate the damage to hull. It is adjusted damage minus minimum armor or armor hp, re-scaled to hull. Due to the way we defined the modifier above, we can  re-scale the damage back to the appropriate modifier for hull by multiplying it by m (since all weapons deal 100% damage to hull) */
      adjusted_d = (adjusted_d - armor)*m;
/* this next line might just as well read hd = adjusted_d; */
      hd = hd + adjusted_d;
    }
  }
  }
  return hd;
}

The reason frag multiplier here is 4 but it is 0.25 for the shield calculation is the asymmetry in frag's multiplier. Specifically for both kinetic and HE, if multipliers are 2 and 0.5 respectively, then shielddamage = damage * multiplier and armordamage = damage / multiplier. And we can then use the same definition for energy when multiplier is 1. But for frag this does not work since the multiplier is 0.25 vs shields and 0.25 vs armor. That is why frag's multiplier should be 0.25 for the shield calculation and 4 for the armor calculation so we have shielddamage = damage * multiplier and armordamage = damage / multiplier for frag also. This is a highly convenient definition since if we define the multiplier this way, then we can simply do (damage/multiplier - armor)*multiplier, with the appropriate max(0,) function and minimum armor added of course, to get hull damage.

I just realized another thing that's missing from this code and that is that damage should not be able to be reduced below a certain threshold. To fix, add
Code
double minimumdamageafterarmorreduction
to the function's arguments and change "double adjusted_d = d*h/m/(armor+h/m)*probmult;" to
Code
double adjusted_d = std::max(d*h/m/(armor+h/m)*probmult, minimumdamageafterarmorreduction*d*probmult);

Full code with fix
code
Code


#operating modes DO NOT CHANGE CODE WILL BREAK IF GUN IS NOT 0 AND BEAM IS NOT 1 AS THE LITERAL INTEGER VALUE IS USED

GUN <- 0
BEAM <- 1
#ships -  NOT FIXED YET EXCEPT DOMINATOR
#ship, hullhp, flux dissipation, maximum flux, startingarmor, widthinpixels, armorcells, shieldwidth, shieldefficacy, shiedlupkeep, name
#glimmer <- c(1500, 250/0.6, 2500/0.6, 200, 78, 5, 78*2, 0.6, "glimmer")
#brawlerlp <- c(2000, 500/0.8, 3000/0.8, 450,110,floor(110/15), "brawlerlp")
#vanguard <- c(3000, 150, 2000, 600, 104, floor(104/15),"vanguard")
#tempest <- c(1250, 225/0.6, 2500/0.6, 200,64,floor(64/15), "tempest")
#medusa <- c(3000,400/0.6,6000/0.6,300,134,floor(134/15), "medusa")
#hammerhead <- c(5000,250/0.8,4200/0.8,500,108,floor(108/16.4), "hammerhead")
#enforcer <- c(4000,200,4000,900,136,floor(136/15), "enforcer")
dominator <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200, "dominator")
#fulgent <- c(5000,300/0.6,5000/0.6,450, 160, floor(160/15), "fulgent")
#brilliant <- c(8000,600/0.6,10000/0.6,900,160,floor(160/20),"brilliant")
#radiant <- c(20000,1500/0.6,25000/0.6,1500,316,floor(316/30),"radiant")
#onslaught <- c(20000,600,17000,1750,288,floor(288/30),"onslaught")
#aurora <- c(8000,800/0.8,11000/0.8,800,128,floor(128/28), "aurora")
#paragon <- c(18000,1250/0.6,25000/0.6,1500,330,floor(330/30),"paragon")
#conquest <- c(12000,1200/1.4,20000/1.4,1200,190,floor(190/30),"conquest")
#champion <- c(10000,550/0.8,10000/0.8,1250, 180,floor(180/24),"champion")

#ships <- list(glimmer,brawlerlp,vanguard,tempest,medusa,hammerhead,enforcer,dominator,fulgent,brilliant,radiant,onslaught,aurora,paragon,conquest,champion)
ships <- list(dominator)
#engagementrange
range <- 1000

minimumarmormultiplier <- 0.05
minimumdamageafterarmorreduction <- 0.15

#weaponaccuracy - this will be made a function of time and weapon later. the accuracy of a hellbore is 10
acc <- 10

#fudge factor
errorsd <- 0.05
#the fudge factor should be a function of range (more error in position at greater range), but not a function of weapon firing angle, and be expressed in terms of pixels
error <- errorsd*range

#time limit for a single combat
time_limit <- 500



beam_tick <- 1/10


G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))


# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)
hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds))
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds)-1)) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds)-1)) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)-1], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      for (j in 1:(length(upperbounds)-1)) vector[j] <- pnorm(upperbounds[j], mean=0, sd=standard_deviation)
      for (j in 2:(length(upperbounds)-1)) vector[j] <- vector[j] - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)] <- 1-pnorm(upperbounds[length(upperbounds)-1], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#this is not really necessary, just a wrapper for the above new function to fit into the old code
createdistribution <- function(acc,mode){
  return(hit_distribution(anglerangevector,error,acc))
}

# this is the default distribution of damage to armor cells
b <- matrix(0,nrow=5,ncol=5)
b[1:5,2:4] <- 1/30
b[2:4,1:5] <- 1/30
b[2:4,2:4] <- 1/15
b[1,1] <- 0
b[1,5] <- 0
b[5,1] <- 0
b[5,5] <- 0

#this function returns the chance to hit a simple width
hitchance <- function(acc,error,lowerpoint,higherpoint){
  return(hit_distribution(c(lowerpoint,higherpoint,0),error,acc)[[2]])
}
#this function generates a sum of matrices multiplied by the distribution

createhitmatrix <- function(acc){
  hitmatrix <- matrix(0,5,ship[6]+4)
  distributionvector <- createdistribution(acc)
  for (i in 1:ship[6]){
    hitmatrix[,i:(i+4)] <- hitmatrix[,i:(i+4)]+b*(distributionvector[i+1])
  }
  return(hitmatrix)
}

#for weapons with damage changing over time we need a sequence of matrices
createhitmatrixsequence <- function(accvector){
  hitmatrixsequence <- list()
  for (i in 1:length(accvector)){
    hitmatrixsequence[[i]] <- createhitmatrix(accvector[i])
  }
  return(hitmatrixsequence)
}


#this function is absolutely performance critical (run millions of times) so write it in C++ using
#elementary operations
library(Rcpp)
#what this function shall do is:
#1 ) modify the armor matrix in the R global environment by subtracting armor damage
#2 ) return hull damage
#A is the armor matrix
#rows_A is the number of rows in A
#cols_A is the number of columns in A
#D is the damage matrix (note: we do not need a rows_D etc as these matrices must be same size)
#a is starting armor for the whole ship
#a_mod is minimum armor
#d is raw damage from whole weapon shot
#h is hit strength of weapon shot
#m is modifier, 2= kinetic, 1 = energy, 0.5 = he, 4 = frag
#hd is hull damage
#do NOT pass a hit strength of 0 to this function as it does not check for dividing by zero
#overall, this function does no sanity or safety checking so be careful with it
cppFunction('double damage(NumericMatrix A, NumericMatrix D, double a, double a_mod, double d, double h, double m, double minimumdamageafterarmorreduction){
  double hd = 0;
  int rows_A = A.nrow();
  int cols_A = A.ncol();
  a = a/15;
  for (int j = 0; j < cols_A; j++){
  for (int i = 0; i < rows_A; i++){
    double armor = std::max(A(i,j), a_mod*a);
    double probmult = D(i,j);
double adjusted_d = std::max(d*h/m/(armor+h/m)*probmult, minimumdamageafterarmorreduction*d*probmult);    if (adjusted_d <= armor){
      A(i,j) = A(i,j) - adjusted_d;
      A(i,j) = std::max(A(i,j), 0.0);
    }
    if (adjusted_d > armor){
      A(i,j) = 0.0;
      adjusted_d = (adjusted_d - armor)*m;
      hd = hd + adjusted_d;
    }
  }
  }
  return hd;
}
')

#general function to generate ticks
#1. general constants
#the interval of discrete time (time lattice parameter) we are using in the model, in seconds
time_interval <- 1
#how long 1 tick of a beam lasts, in seconds
beam_tick <- 1/10
#minimum interval that exists in the game, in case a modder has somehow specified a lower value for something
global_minimum_time <- 0.05
#operating modes
UNLIMITED <- -1

#times in seconds, ammoregen is in ammo / second
hits <- function(chargeup, chargedown, burstsize, burstdelay, ammo=UNLIMITED, ammoregen=0, reloadsize=0, traveltime=0, mode=GUN){
  #specify sane minimum delays, since the game enforces weapons can only fire once every 0.05 sec
  #for beams, refiring delay is given by burstdelay, for guns it is burstdelay in case burstdelay is > 0 (==0 is shotgun) and chargedown
  if(burstdelay > 0 | mode == BEAM) burstdelay <- max(burstdelay, global_minimum_time)
  if(mode == GUN) chargedown <- max(chargedown, global_minimum_time)
  #this vector will store all the hit time coordinates
  #current time
  #insert a very small fraction here to make time round correctly
  time <- 0.001
  #maximum ammo count is ammo given at start
  maxammo <- ammo
  #this is used to do ammo regeneration, 0 = not regenerating ammo, 1 = regenerating ammo
  regeneratingammo <- 0
  ammoregentimecoordinate <- 0
  ammoregenerated <- 0
 
  #we are firing a gun
  if (mode == GUN) {
    Hits <- vector(mode="double", length = 0)
    while(time < time_limit){
      time <- time + chargeup
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
     
      if (burstdelay == 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            ammo <- ammo - 1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
          }
        }
      }
      if (burstdelay > 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            time <- time + burstdelay
            ammo <- ammo -1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
           
          }
        }
      }
      time <- time+chargedown
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
    }
    timeseries <- vector(mode="integer", length = time_limit/time_interval)
    timeseries[1] <- length(Hits[Hits >= 0 & Hits <= 1*time_interval])
    for (i in 2:time_limit/time_interval) timeseries[i] <- length(Hits[Hits > (i-1)*time_interval & Hits <= i*time_interval])
    return(timeseries)
  }
  #we are firing a beam
  if (mode == BEAM) {
    chargeup_ticks <- chargeup/beam_tick
    chargedown_ticks <- chargedown/beam_tick
    burst_ticks <- burstsize/beam_tick
    #for a beam we will instead use a matrix to store timepoint and beam intensity at timepoint
    beam_matrix <- matrix(nrow=0,ncol=2)
    #burst size 0 <- the beam never stops firing
    if(burstsize == 0){
      for (i in 1:chargeup_ticks) {
        #beam intensity scales quadratically during chargeup, so
      }
      while ( time < time_limit) {
        beam_matrix <- rbind(beam_matrix,c(time, 1))
        time <- time+beam_tick
      }
    } else {
      while (time < time_limit) {
        if (ammo != 0){
          ammo <- ammo - 1
          if (chargeup_ticks > 0){
            for (i in 1:chargeup_ticks) {
              beam_matrix <- rbind(beam_matrix,c(time, (i*beam_tick)^2))
              time <- time+beam_tick
              if (regeneratingammo == 0) {
                ammoregentimecoordinate <- time
                regeneratingammo <- 1
              }
              if(time - ammoregentimecoordinate > 1/ammoregen){
                ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
                ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
                if(ammoregenerated >= reloadsize){
                  ammo <- ammo+ ammoregenerated
                  ammoregenerated <- 0
                }
                if(ammo >= maxammo){
                  ammo <- maxammo
                  regeneratingammo <- 0
                }
              }
            }
          }
          for (i in 1:burst_ticks){
            beam_matrix <- rbind(beam_matrix,c(time, 1))
            time <- time+beam_tick
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
         
          if (chargedown_ticks > 0){
            for (i in 1:chargedown_ticks){
              beam_matrix <- rbind(beam_matrix,c(time, ((chargedown_ticks-i)*beam_tick)^2))
              time <- time+beam_tick
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
          time <- time + burstdelay
          if(time - ammoregentimecoordinate > 1/ammoregen){
            ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
            ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
            if(ammoregenerated >= reloadsize){
              ammo <- ammo+ ammoregenerated
              ammoregenerated <- 0
            }
            if(ammo >= maxammo){
              ammo <- maxammo
              regeneratingammo <- 0
            }
          }
        }
        time <- time + global_minimum_time
        if(time - ammoregentimecoordinate > 1/ammoregen){
          ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
          ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
          if(ammoregenerated >= reloadsize){
            ammo <- ammo+ ammoregenerated
            ammoregenerated <- 0
          }
          if(ammo >= maxammo){
            ammo <- maxammo
            regeneratingammo <- 0
          }
        }
      }
    }
    timeseries <- vector(mode="double", length = time_limit/time_interval)
    for (i in 1:length(timeseries)) {
      timeseries[i] <- sum(beam_matrix[beam_matrix[,1] < i & beam_matrix[,1] > i-1,2])
    }
    return(timeseries)
  }
}

squalltics <- hits(0,10,20,0.5)
locusttics <- hits(0,5,40,0.1)
#special
hurricanetics <- hits(0,15,9,0)
harpoontics <- hits(0,8.25,4,0.25)
sabottics <- hits(0,8.75,2,0.25)
gausstics <- hits(1,1,1,0)
ionbeamtics <- hits(0.1,0.1,0,0,mode=BEAM)

#WEAPON ACCURACY
#missiles do not have spread
squallacc <- c(0)
locustacc <- c(0)
hurricaneacc <- c(0)
harpoonacc <- c(0)
sabotacc <- c(0)

#gauss has a spread of 0 and no increase per shot
gaussacc <- c(0)
#hephaestus has a spread of 0 and it increases by 2 per shot to a max of 10
#hephaestusacc <- c(seq(0,10,2))
#mark ix has a spread of 0 and it increases by 2 per shot to a max of 15
#markixacc <- c(seq(0,15,2),15)
#mjolnir has a spread of 0 and it increases by 1 per shot to a max of 5
#mjolniracc <- c(seq(1,5,1))
#hellbore has a spread of 10
#hellboreacc <- c(10)
#storm needler has a spread of 10
#stormneedleracc <- c(10)
ionbeamacc <- c(0)

#damage per shot, damage type (2=kinetic, 0.5=he, 0.25=frag, 1=energy), tics, weapon name, weapon accuracy over time, hit chance, mode
squall <- list(250, 2, squalltics, "Squall", squallacc, GUN)
locust <- list(200, 0.25, locusttics, "Locust", locustacc, GUN)
hurricane <- list(500, 0.5, hurricanetics, "Hurricane", hurricaneacc, GUN)
harpoon <- list(750, 0.5, harpoontics, "Harpoon", harpoonacc, GUN)
sabot <- list(200, 2, sabottics, "Sabot", sabotacc, GUN)
gauss <- list(700, 2, gausstics, "Gauss", gaussacc, GUN)
#hephaestus <- list(120, 0.5, hephaestustics, "Hephaestus", hephaestusacc)
#markix <- list(200, 2, markixtics, "Mark IX", markixacc)
#mjolnir <- list(400, 1, mjolnirtics, "Mjolnir", mjolniracc)
#hellbore <- list(750, 0.5, hellboretics, "Hellbore", hellboreacc)
#stormneedler <- list(50, 2, stormneedlertics, "Storm Needler", stormneedleracc)

#for beams, damage per second, and then the rest as previously
ionbeam <- list(1000, 1, ionbeamtics, "Super Ion Beam", ionbeamacc, BEAM)
dummy <- list(0,0,c(seq(0,time_limit,1)),"",c(0),c(0),GUN)

#which weapons are we studying?

weapon1choices <- list(squall, locust, hurricane)
weapon2choices <- list(squall, locust, hurricane)
weapon3choices <- list(harpoon, sabot)
weapon4choices <- list(harpoon, sabot)
weapon5choices <- list(ionbeam, gauss)
weapon6choices <- list(ionbeam, gauss)
weapon7choices <- list(ionbeam, gauss)
weapon8choices <- list(ionbeam, gauss)

#how many unique weapon loadouts are there?

#get names of weapons from a choices list x
getweaponnames <- function(x){
  vector <- vector(mode="character")
  for (i in 1:length(x)){
    vector <- cbind(vector, x[[i]][[4]])
  }
  return(vector)
}
#convert the names back to numbers when we are done based on a weapon choices list y
convertweaponnames <- function(x, y){
  vector <- vector(mode="integer")
  for (j in 1:length(x)) {
    for (i in 1:length(y)){
      if(x[j] == y[[i]][[4]]) vector <- cbind(vector, i)
    }
  }
  return(vector)
}

#this section of code generates a table of all unique loadouts that we can create using the weapon choices available
generatepermutations <- 0
if (generatepermutations == 1){
  #enumerate weapon choices as integers
 
  perm1 <- seq(1,length(weapon1choices),1)
  perm2 <- seq(1,length(weapon2choices),1)
  perm3 <- seq(1,length(weapon3choices),1)
  perm4 <- seq(1,length(weapon4choices),1)
  perm5 <- seq(1,length(weapon5choices),1)
  perm6 <- seq(1,length(weapon6choices),1)
  perm7 <- seq(1,length(weapon7choices),1)
  perm8 <- seq(1,length(weapon8choices),1)
 
  #create a matrix of all combinations
  perm1x2 <- expand.grid(perm1,perm2)
  #sort, then only keep unique rows
  perm1x2 <- unique(t(apply(perm1x2, 1, sort)))
 
  perm3x4 <- expand.grid(perm3,perm4)
  perm3x4 <- unique(t(apply(perm3x4, 1, sort)))
 
  perm5x6 <- expand.grid(perm5,perm6)
  perm5x6 <- unique(t(apply(perm5x6, 1, sort)))
 
  perm7x8 <- expand.grid(perm7,perm8)
  perm7x8 <- unique(t(apply(perm7x8, 1, sort)))
 
  #now that we have all unique combinations of all two weapons, create a matrix containing all combinations of these unique combinations
  allperms <- matrix(0,0,(length(perm1x2[1,])+length(perm3x4[1,])+length(perm5x6[1,])+length(perm7x8[1,])))
  for(i in 1:length(perm1x2[,1])) for(j in 1:length(perm3x4[,1])) for(k in 1:length(perm5x6[,1])) for(l in 1:length(perm7x8[,1])) allperms <- rbind(allperms, c(perm1x2[i,],perm3x4[j,],perm5x6[k,],perm7x8[l,])
  )
  #this is just for testing, can remove
  allperms
  #we save this so we don't have to compute it again
  saveRDS(allperms, file="allperms.RData")
 
} else {
  allperms <- readRDS("allperms.RData")
}

#now compute a main lookuptable to save on computing time
#the lookuptable should be a list of lists, so that
#lookuptable[[ship]][[weapon]][[1]] returns hit chance vector and
#lookuptable[[ship]][[weapon]][[2]] returns hit probability matrix
#time for some black R magic

#note: the lookuptable will be formulated such that there is a running index of weapons rather than sub-lists, so all weapons will be indexed consecutively so we have lookuptable [[1]][[1]] = [[ship1]][[weaponchoices1_choice1]], etc. So that is what the below section does.

#read or generate lookuptable
generatelookuptable <- 0
if(generatelookuptable == 1){
 
  lookuptable <- list()
 
  for (f in 1:length(ships)){
    lookuptable[[f]] <- list()
    ship <- ships[[f]]
    ship <- as.double(ship[1:9])
    #how much is the visual arc of the ship in rad?
    shipangle <- ship[5]/(2* pi *range)
   
    #how much is the visual arc of a single cell of armor in rad?
    cellangle <- shipangle/ship[6]
   
    #now assume the weapon is targeting the center of the ship's visual arc and that the ship is in the center of the weapon's firing arc
    #which cell will the shot hit, or will it miss?
    #call the cells (MISS, cell1, cell2, ... ,celli, MISS) and get a vector giving the (maximum for negative / minimum for positive) angles for hitting each
    anglerangevector <- vector(mode="double", length = ship[6]+1)
    anglerangevector[1] <- -shipangle/2
    for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle
    #now convert it to pixels
    anglerangevector <- anglerangevector*2*pi*range
   
    weaponindexmax <- length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+length(weapon8choices)
   
    for (x in 1:weaponindexmax) {
      print(x)
      if(x <= length(weapon1choices)){
        weapon1<-weapon1choices[[x]]
        if(weapon1[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon1[[5]]))
          for (i in 1:length(weapon1[[5]])){
            hitchancevector[i] <- hitchance(weapon1[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon1[[5]])
        }
      }
      if((x > length(weapon1choices)) &  (x <= length(weapon1choices) + length(weapon2choices))){
        weapon2<-weapon2choices[[x-length(weapon1choices)]]
        if(weapon2[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon2[[5]]))
          for (i in 1:length(weapon2[[5]])){
            hitchancevector[i] <- hitchance(weapon2[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon2[[5]])
        }
      }
     
      if((x > length(weapon1choices) + length(weapon2choices)) &  (x <= length(weapon2choices) + length(weapon1choices) + length(weapon3choices))){
        weapon3<-weapon3choices[[x-length(weapon2choices)-length(weapon1choices)]]
        if(weapon3[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon3[[5]]))
          for (i in 1:length(weapon3[[5]])){
            hitchancevector[i] <- hitchance(weapon3[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon3[[5]])
        }
      } 
     
      if((x > length(weapon2choices) + length(weapon1choices) + length(weapon3choices)) &  (x <= length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon4choices))){
        weapon4<-weapon4choices[[x-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon4[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon4[[5]]))
          for (i in 1:length(weapon4[[5]])){
            hitchancevector[i] <- hitchance(weapon4[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon4[[5]])
        }
      }
      if((x > length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon4choices)) &  (x <= length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon5choices))){
        weapon5<-weapon5choices[[x-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon5[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon5[[5]]))
          for (i in 1:length(weapon5[[5]])){
            hitchancevector[i] <- hitchance(weapon5[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon5[[5]])
        }
      }
      if((x > length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon5choices)) &  (x <= length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon6choices))){
        weapon6<-weapon6choices[[x-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon6[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon6[[5]]))
          for (i in 1:length(weapon6[[5]])){
            hitchancevector[i] <- hitchance(weapon6[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon6[[5]])
        }
      }
      if((x > length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon6choices)) &  (x <= length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon7choices))){
        weapon7<-weapon7choices[[x-length(weapon6choices)-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon7[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon7[[5]]))
          for (i in 1:length(weapon7[[5]])){
            hitchancevector[i] <- hitchance(weapon7[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon7[[5]])
        }
      }
      if((x > length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon7choices)) &  (x <= length(weapon7choices) + length(weapon6choices) + length(weapon5choices) + length(weapon4choices) + length(weapon3choices) + length(weapon2choices) + length(weapon1choices) + length(weapon8choices))){
        weapon8<-weapon8choices[[x-length(weapon7choices)-length(weapon6choices)-length(weapon5choices)-length(weapon4choices)-length(weapon3choices)-length(weapon2choices)-length(weapon1choices)]]
        if(weapon8[4] != ""){
          hitchancevector <- vector(mode = "double", length = length(weapon8[[5]]))
          for (i in 1:length(weapon8[[5]])){
            hitchancevector[i] <- hitchance(weapon8[[5]][i],error,-ship[[7]]/2,ship[[7]]/2)
          }
          lookuptable[[f]][[x]] <- list()
          lookuptable[[f]][[x]][[1]] <- hitchancevector
          lookuptable[[f]][[x]][[2]] <- createhitmatrixsequence(weapon8[[5]])
        }
      }
     
    }
  }
  # save this so we don't have to re-compute it
  saveRDS(lookuptable, file="lookuptable.RData")
} else {
  lookuptable <- readRDS("lookuptable.RData")
}

lookup <- function(ship, weapon, var) return(lookuptable[[ship]][[weapon]][[var]])

#go through all ships
for (f in 1:length(ships)){
 
  ship <- ships[[f]]
  #format ship data types appropriately
  shipname <- ship[[10]]
  ship <- as.double(ship[1:9])
 
 
 
 
 
 
 
  timeseriesarray <- data.frame(matrix(ncol = 7,nrow=0))
 
 
 
  timetokill=0
 
 
  shieldblock <- 0
 

 
  timeseries <- function(timepoint, softflux, hardflux, armorhp, hullhp, fluxdissip, fluxcap, startingarmor,armormatrix){
    weaponacc <- 0

    #are we using shield to block?
    shieldblock <- 0
    hulldamage <- 0
   
   
    #weapon 1
    weapon1mult <- weapon1[[2]]
    weapon2mult <- weapon2[[2]]
    weapon3mult <- weapon3[[2]]
    weapon4mult <- weapon4[[2]]
    weapon5mult <- weapon5[[2]]
    weapon6mult <- weapon6[[2]]
    weapon7mult <- weapon7[[2]]
    weapon8mult <- weapon8[[2]]
   
    shots <- weapon1[[3]][[timepoint]]
    #here we must convert beam ticks to fractional shots
    shots1 <- weapon1[[3]][[timepoint]]*beam_tick^(weapon1[[6]])
    shots2 <- weapon2[[3]][[timepoint]]*beam_tick^(weapon2[[6]])
    shots3 <- weapon3[[3]][[timepoint]]*beam_tick^(weapon3[[6]])
    shots4 <- weapon4[[3]][[timepoint]]*beam_tick^(weapon4[[6]])
    shots5 <- weapon5[[3]][[timepoint]]*beam_tick^(weapon5[[6]])
    shots6 <- weapon6[[3]][[timepoint]]*beam_tick^(weapon6[[6]])
    shots7 <- weapon7[[3]][[timepoint]]*beam_tick^(weapon7[[6]])
    shots8 <- weapon8[[3]][[timepoint]]*beam_tick^(weapon8[[6]])
    #test is used to determine if we are firing or blocking this turn
    test <- (fluxcap-softflux-hardflux - weapon1[[1]]*weapon1mult*shots1 - weapon2[[1]]*weapon2mult*shots2 - weapon3[[1]]*weapon3mult*shots3 - weapon4[[1]]*weapon4mult*shots4 - weapon5[[1]]*weapon5mult*shots5 - weapon6[[1]]*weapon6mult*shots6 - weapon7[[1]]*weapon7mult*shots7 - weapon8[[1]]*weapon8mult*shots8)   
    #skip the whole thing if we are not firing
    if (weapon1[[4]] !="" & shots > 0){
      mode <- weapon1[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon1[[1]]*weapon1mult*weapon1[[7]][min(weapon1shots,length(weapon1[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon1[[1]]*weapon1[[3]][[timepoint]]*beam_tick*weapon1mult*weapon1[[7]][min(weapon1shots,length(weapon1[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon1[2])==0.25){weapon1mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon1[[1]]
            damage <- weapon1[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon1[[1]]/2
            damage <- weapon1[[1]]*weapon1[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon1[[8]][[min(weapon1shots,length(weapon1[[8]]))]],startingarmor,minimumarmormultiplier,damage,hitstrength,weapon1mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon1shots <- weapon1shots + weapon1[[3]][timepoint]
    }
   
    #repeat for other weapons
    shots <- weapon2[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon2[[4]] !="" & shots > 0){
      weapon2mult <- weapon2[[2]]
      mode <- weapon2[[6]]
     
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon2[[1]]*weapon2mult*weapon2[[7]][min(weapon2shots,length(weapon2[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon2[[1]]*weapon2[[3]][[timepoint]]*beam_tick*weapon2mult*weapon2[[7]][min(weapon2shots,length(weapon2[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon2[2])==0.25){weapon2mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon2[[1]]
            damage <- weapon2[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon2[[1]]/2
            damage <- weapon2[[1]]*weapon2[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon2[[8]][[min(weapon2shots,length(weapon2[[8]]))]],startingarmor,minimumarmormultiplier,weapon2[[1]],hitstrength,weapon2mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon2shots <- weapon2shots + weapon2[[3]][timepoint]
    }
   
   
    shots <- weapon3[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon3[[4]] !="" & shots > 0){
      weapon3mult <- weapon3[[2]]
      mode <- weapon3[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon3[[1]]*weapon3mult*weapon3[[7]][min(weapon3shots,length(weapon3[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon3[[1]]*weapon3[[3]][[timepoint]]*beam_tick*weapon3mult*weapon3[[7]][min(weapon3shots,length(weapon3[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon3[2])==0.25){weapon3mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon3[[1]]
            damage <- weapon3[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon3[[1]]/2
            damage <- weapon3[[1]]*weapon3[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon3[[8]][[min(weapon3shots,length(weapon3[[8]]))]],startingarmor,minimumarmormultiplier,weapon3[[1]],hitstrength,weapon3mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon3shots <- weapon3shots + weapon3[[3]][timepoint]
    }
   
   
    shots <- weapon4[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon4[[4]] !="" & shots > 0){
      weapon4mult <- weapon4[[2]]
      mode <- weapon4[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon4[[1]]*weapon4mult*weapon4[[7]][min(weapon4shots,length(weapon4[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon4[[1]]*weapon4[[3]][[timepoint]]*beam_tick*weapon4mult*weapon4[[7]][min(weapon4shots,length(weapon4[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon4[2])==0.25){weapon4mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon4[[1]]
            damage <- weapon4[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon4[[1]]/2
            damage <- weapon4[[1]]*weapon4[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon4[[8]][[min(weapon4shots,length(weapon4[[8]]))]],startingarmor,minimumarmormultiplier,weapon4[[1]],hitstrength,weapon4mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon4shots <- weapon4shots + weapon4[[3]][timepoint]
    }
   
   
    shots <- weapon5[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon5[[4]] !="" & shots > 0){
      weapon5mult <- weapon5[[2]]
      mode <- weapon5[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon5[[1]]*weapon5mult*weapon5[[7]][min(weapon5shots,length(weapon5[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon5[[1]]*weapon5[[3]][[timepoint]]*beam_tick*weapon5mult*weapon5[[7]][min(weapon5shots,length(weapon5[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon5[2])==0.25){weapon5mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon5[[1]]
            damage <- weapon5[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon5[[1]]/2
            damage <- weapon5[[1]]*weapon5[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon5[[8]][[min(weapon5shots,length(weapon5[[8]]))]],startingarmor,minimumarmormultiplier,weapon5[[1]],hitstrength,weapon5mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon5shots <- weapon5shots + weapon5[[3]][timepoint]
    }
   
    shots <- weapon6[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon6[[4]] !="" & shots > 0){
      weapon6mult <- weapon6[[2]]
      mode <- weapon6[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon6[[1]]*weapon6mult*weapon6[[7]][min(weapon6shots,length(weapon6[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon6[[1]]*weapon6[[3]][[timepoint]]*beam_tick*weapon6mult*weapon6[[7]][min(weapon6shots,length(weapon6[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon6[2])==0.25){weapon6mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon6[[1]]
            damage <- weapon6[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon6[[1]]/2
            damage <- weapon6[[1]]*weapon6[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon6[[8]][[min(weapon6shots,length(weapon6[[8]]))]],startingarmor,minimumarmormultiplier,weapon6[[1]],hitstrength,weapon6mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon6shots <- weapon6shots + weapon6[[3]][timepoint]
    }
   
    shots <- weapon7[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon7[[4]] !="" & shots > 0){
      weapon7mult <- weapon7[[2]]
      mode <- weapon7[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon7[[1]]*weapon7mult*weapon7[[7]][min(weapon7shots,length(weapon7[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon7[[1]]*weapon7[[3]][[timepoint]]*beam_tick*weapon7mult*weapon7[[7]][min(weapon7shots,length(weapon7[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon7[2])==0.25){weapon7mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon7[[1]]
            damage <- weapon7[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon7[[1]]/2
            damage <- weapon7[[1]]*weapon7[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon7[[8]][[min(weapon7shots,length(weapon7[[8]]))]],startingarmor,minimumarmormultiplier,weapon7[[1]],hitstrength,weapon7mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon7shots <- weapon7shots + weapon7[[3]][timepoint]
    }
   
    shots <- weapon8[[3]][[timepoint]]
   
   
    #skip the whole thing if we are not firing
    if (weapon8[[4]] !="" & shots > 0){
      weapon8mult <- weapon8[[2]]
      mode <- weapon8[[6]]
      if(mode == BEAM) {
        shots <- 1
      }
      for (s in 1:shots){
        #1. use shield to block if you can
        if (test > 0){
          #hard flux
          if(mode == GUN){
            hardflux <- hardflux + weapon8[[1]]*weapon8mult*weapon8[[7]][min(weapon8shots,length(weapon8[[7]]))]*shieldefficacy
            hardflux <- min(hardflux, fluxcap-softflux)
          }
          if(mode == BEAM){
            softflux <- softflux + weapon8[[1]]*weapon8[[3]][[timepoint]]*beam_tick*weapon8mult*weapon8[[7]][min(weapon8shots,length(weapon8[[7]]))]*shieldefficacy
            softflux <- min(softflux, fluxcap-hardflux)
          }
          shieldblock <- 1
        } else {
          #2. if you did not use shield to block, damage armor and hull
          #frag is a special case wrt multiplier
          if(unlist(weapon8[2])==0.25){weapon8mult = 4}
          #2.1. damage armor and hull
          hitstrength <- 0
          damage <- 0
          if(mode == GUN) {
            hitstrength <- weapon8[[1]]
            damage <- weapon8[[1]]
          }
          if(mode == BEAM) {
            hitstrength <- weapon8[[1]]/2
            damage <- weapon8[[1]]*weapon8[[3]][[timepoint]]*beam_tick
          }
          hulldamage <- damage(armormatrix,weapon8[[8]][[min(weapon8shots,length(weapon8[[8]]))]],startingarmor,minimumarmormultiplier,weapon8[[1]],hitstrength,weapon8mult,minimumdamageafterarmorreduction)
          hullhp <- hullhp - hulldamage
          hullhp <- max(hullhp, 0)
        }
      }
      weapon8shots <- weapon8shots + weapon8[[3]][timepoint]
    }
   
    armorhp <- sum(armormatrix)*15/((ship[[6]]+4)*5)
    if(hullhp==0) armorhp <- 0
   
    if (shieldblock != 0) fluxdissip <- fluxdissip - shieldupkeep
   
    if (softflux > 0){
      if (softflux > fluxdissip) softflux <- softflux - fluxdissip
      else {
        fluxdissip <- max(0,fluxdissip - softflux)
        softflux <- 0
      }
    }
    if (hardflux > 0 & shieldblock == 0){
      hardflux <- max(0,hardflux - fluxdissip)
    }
    if(hullhp > 0){} else {
      softflux <- 0
      hardflux <- 0
    }
    return(list(timepoint, softflux, hardflux, armorhp, hullhp, fluxdissip, fluxcap, startingarmor,armormatrix))
  }
 
  totaltime = 500
 
 
 
  armorhp <- ship[4]
  shieldhp <- ship[3]
  hullhp <- ship[1]
  fluxdissip <- ship[2]
  softflux <- 0
  hardflux <- 0
  fluxcap <- ship[3]
  armorhp <- ship[4]
  startingarmor <- ship[4]
  shieldefficacy <- ship[8]
  shieldupkeep <- ship[9]
 
  weapon1shots <- 1
  weapon2shots <- 1
  weapon3shots <- 1
  weapon4shots <- 1
  weapon5shots <- 1
  weapon6shots <- 1
  weapon7shots <- 1
  weapon8shots <- 1
 
  armormatrix <- matrix(ship[4]/15,5,ship[6]+4)
 
  #now what we do here is we go through all the permutations using the running index, which is i+j+k+l+m+n+o+p for weapons 8
  for (z in 1:length(allperms[,1])) {
    i <- allperms[z,1]
    j <- allperms[z,2]
    k <- allperms[z,3]
    l <- allperms[z,4]
    m <- allperms[z,5]
    n <- allperms[z,6]
    o <- allperms[z,7]
    p <- allperms[z,8]
   
    #for (i in 1:length(weapon1choices)) {
    weapon1<-weapon1choices[[i]]
    #  for (j in 1:length(weapon2choices)) {
    weapon2<-weapon2choices[[j]]
    #    for (k in 1:length(weapon3choices)) {
    weapon3<-weapon3choices[[k]]
    #      for (l in 1:length(weapon4choices)) {
    weapon4<-weapon4choices[[l]]
    #        for (m in 1:length(weapon5choices)) {
    weapon5<-weapon5choices[[m]]
    #          for (n in 1:length(weapon6choices)) {
    weapon6<-weapon6choices[[n]]
    #            for (o in 1:length(weapon7choices)) {
    weapon7<-weapon7choices[[o]]
    #              for (p in 1:length(weapon8choices)) {
    weapon8<-weapon8choices[[p]]
    #lookup <- function(ship, weapon, var) return(lookuptable[[ship]][[weapon]][[var]])
    if(weapon1[4] != ""){
      weapon1[[7]] <- lookup(f,i,1)
      weapon1[[8]] <- lookup(f,i,2)
    }
   
    if(weapon2[4] != ""){
      weapon2[[7]] <- lookup(f,length(weapon1choices)+j,1)
      weapon2[[8]] <- lookup(f,length(weapon1choices)+j,2)
    }
   
    if(weapon3[4] != ""){
      weapon3[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+k,1)
      weapon3[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+k,2)
    }
   
    if(weapon4[4] != ""){
      weapon4[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+l,1)
      weapon4[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+l,2)
    }
   
    if(weapon5[4] != ""){
      weapon5[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+m,1)
      weapon5[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+m,2)
    }
   
    if(weapon6[4] != ""){
      weapon6[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+n,1)
      weapon6[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+n,2)
    }
    if(weapon7[4] != ""){
      weapon7[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+o,1)
      weapon7[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+o,2)
    }
    if(weapon8[4] != ""){
      weapon8[[7]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+p,1)
      weapon8[[8]] <- lookup(f,length(weapon1choices)+length(weapon2choices)+length(weapon3choices)+length(weapon4choices)+length(weapon5choices)+length(weapon6choices)+length(weapon7choices)+p,2)
    }
   
    #time series - run time series at point t, save it to state, update values according to state, re-run time series, break if ship dies
    for (t in 1:totaltime){
      state <- timeseries(t,softflux,hardflux,armorhp,hullhp,fluxdissip,fluxcap,startingarmor,armormatrix)
      softflux <- state[[2]]
      hardflux <- state[[3]]
      armorhp <- state[[4]]
      hullhp <- state[[5]]
      flux <- softflux + hardflux
      armormatrix <- state[[9]]
      if(hullhp == 0){flux <- 0
      if (timetokill == 0){timetokill <- t
      break}
      }
     
    }
    if (timetokill ==0){timetokill <- NA}
   
    tobind <- c(timetokill,unlist(weapon1[4]),unlist(weapon2[4]),unlist(weapon3[4]),unlist(weapon4[4]),unlist(weapon5[4]),unlist(weapon6[4]),unlist(weapon7[4]),unlist(weapon8[4]))
    timeseriesarray <- rbind(timeseriesarray,tobind)
   
    armorhp <- ship[4]
    shieldhp <- ship[3]
    hullhp <- ship[1]
    fluxdissip <- ship[2]
    softflux <- 0
    hardflux <- 0
    fluxcap <- ship[3]
    armorhp <- ship[4]
    startingarmor <- ship[4]
    shieldefficacy <- ship[8]
    shieldupkeep <- ship[9]
   
    weapon1shots <- 1
    weapon2shots <- 1
    weapon3shots <- 1
    weapon4shots <- 1
    weapon5shots <- 1
    weapon6shots <- 1
    weapon7shots <- 1
    weapon8shots <- 1
    armormatrix <- matrix(ship[4]/15,5,ship[6]+4)
    timetokill <- 0
    #          }
    #        }
    #      }
    #    }
    #  }
    #}
    # }
  }
  #}
  colnames(timeseriesarray) <-  c("Timetokill", "Weapon1", "Weapon2", "Weapon3", "Weapon4", "Weapon5", "Weapon6", "Weapon7", "Weapon8")
 
  sortbytime <- timeseriesarray[order(as.integer(timeseriesarray$Timetokill)),]
 
  write.table(sortbytime, file = paste("optimizeweaponsbytime",shipname,"allweaponswithacc.txt", sep=""), row.names=FALSE, sep="\t")
}
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 22, 2022, 01:56:04 AM
I think Liral already got this in their code (If I am interpreting C++ correctly), but if you want to make things fast, you should pre-multiply the armor/shield damage multipliers before the simulation and pass in the computed values of damage against each target type, so you avoid doing the multiplications over and over in every damage calculation. In that case, it really doesn't matter how you represent the damage multipliers and using the conventional form with separate armor/shield/hull multipliers is much less confusing IMO (rather than having shield damage multipliers floating around in armor calculations).

Also allowing for support of arbitrary damage types where hull_mult =/= 1 and or armor_mult =/= 1/shield_mult is good IMO. In that case, you should multiply the overkill armor damage by (hull_mult/armor_mult) rather than multiplying by shield_mult. I think that is much clearer to the reader as well since it conveys that we are 'converting armor damage into hull damage' almost like a unit conversion. That covers frag damage systematically, and also theoretically could cover any custom modded damage types (with whatever multipliers) that you could imagine.

But looking at Liral's code, I think there is some stuff missing (where is the pooling of armor values over adjacent cells and all that?). I'm just gonna post my MATLAB code (with some minor modifications to help non-MATLAB users read it).  I think this is correct (matched results with other peoples sims) and maybe we can confirm we are all doing the same thing.

Code
# a function to update the armor grid and hull. It should be called in a loop over all possible shot locations when doing expected value calculations, otherwise, set hit_prob to 1 for exact damage calculations 

function [armor_grid, hull] = armorUpdate(dmg, location, armor_grid, hull, armor_max, hit_prob)
    # function to get the locations of inner and outer cells given the hit location
    [innerCells, outerCells] = getArmorIndexes(location, armor_grid);

    # total armor value of the weighted sum of inner and outer armor cells, accounting for minimum armor
    armor_pooled = max( (sum(armor_grid[innerCells]) + 1/2 * sum(armor_grid[outerCells])) , .05 * armor_max);

    # calculates the armor damage multiplier accounting for the maximum damage reduction
    armor_damage_mult = max(dmg[2] / (dmg[2] + armor_pooled), .15);

    # compute an array that determines the fraction of the total shot damage that goes to each of the armor cells
    damage_grid_mult = (1/15 * innerCells .* + 1/30 * outerCells) .* armor_damage_mult;
   
    # compute the damage dealt to each armor cell (could combine this with the last line to avoid a temporary variable, but this is     
    # more readable IMO) also accounts for hit probability
    damage_armor = damage_grid_mult .* dmg[2] * hit_prob;

    # compute the damage dealt to hull from each armor cell, accounting for overkill damage to armor
    damage_hull = max(damage_armor - armor_grid, 0) * dmg[3]/dmg[2];

    # add up the total damage to hull and reduce the true hull by that amount
    hull = hull - sum(damage_hull, 'all');
 
    # update the armor grid, note that it is very important to do this after the hull damage is calculated
    armor_grid = max(armor_grid - damage_armor, 0);
   
end

a couple notes:
[innerCells, outerCells] = getArmorIndexes(location, armor_grid);
Calls a function I wrote which gets the indexes of the inner and outer cells given the hit location. innerCells and outerCells are boolean arrays of the same size as armor_grid which have true in all the locations of the inner ring of cells around the hit and false everywhere else (and similarly for outerCells having the second ring of cells around the hit, excluding corners). Then an expression like armor_grid[innerCells] is using logical indexing to return a vector of the armor values of all of the inner cells.

dmg is a vector with the damage to shield, armor and hull pre-calculated accounting for damage multipliers (in that order). This is also a good time to note that MATLAB indexing starts at 1.

also 'dot operations' like .* are element-wise versions of standard operations. So if I have two array A and B of the same size, A .* B will return the element-wise multiplication, while A*B will try to return the matrix multiplication (which would error out if the arrays are not square).

another note: MATLAB implies element-wise multiplication and addition if you have a scalar and an array. So A + b where A is a matrix/array and b is a scalar would return b added to each element of A.

I think that with numpy, python code should look very similar (obviously adjusting syntax for python), but I'm not sure if all the quirks/tricks with logical indexing and element wise operations are 1 to 1 in numpy.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 22, 2022, 05:04:09 AM
Wow, my C++ code was pretty terrible it turns out, as indeed it should have an extra line for armor pooling too for the hit strength calculation and right now it's just comparing the total hit strength to one single cell's armor. Sorry about that. The extra lines should read

double armorpooled=0;
for(int x=0; x < 5; x++){
for(int y=0; y < 5; y++){
armorpooled = armorpooled + std::max(A((i-2)+x,y)/2, a_mod * a/2);
}
}
for(int x=0; x < 3; x++){
for(int y=0; y < 3; y++){
armorpooled = armorpooled + std::max(A((i-1)+x,y+1)/2, a_mod * a/2);
}
}
double adjusted_d = std::max(d*h/m/(armorpooled+h/m)*probmult, minimumdamageafterarmorreduction*d*probmult);   

for example.

Anyway, your code seems mostly right, however there is one extra thing which is that hit strength and damage must be two different things. This is because for beams we have hit strength = dps / 2 but damage = beam ticks(adjusted for chargeup/chargedown)*dps/beam tick. We get the adjusted beam ticks from the hit time series function for each second. And, I'm not an expert on armor, but I think Vanshilar said earlier that cells with 0 armor can contribute to minimum armor even if pooled armor is not yet at the minimum armor threshold so that affects the order of sum and max operations. Vanshilar, can you confirm if I have this right?

So anyway let's try that commenting thing again:

Code

/* NumericMatrix leads to the code modifying the matrix in the R global environment on lines 10 to 17. If this is not possible in Python, then the function should return the damaged matrix. The function should also return a double containing the hull damage that goes through. */
double damage(NumericMatrix A, NumericMatrix D, double a, double a_mod, double d, double h, double m){
  double hd = 0;
  int rows_A = A.nrow();
  int cols_A = A.ncol();
/* see above - we were passed the starting armor of the ship, and now we calculate the starting armor of an individual cell */
  a = a/15;
/* go through the armor matrix */
  for (int j = 0; j < cols_A; j++){
  for (int i = 0; i < rows_A; i++){
/* "armor" is the value we will use for the hit strength calculation. IF the armor matrix hp at (i,j) is greater than minimum armor (e.g a_mod=0.05, a=1500/15 (standard dominator) -> minimum armor is 0.05*100) THEN use armor matrix hp ELSE use the minimum armor value for the hit strength calculation */
    double armor = std::max(A(i,j), a_mod*a);
/* probmult refers to the fraction of damage we are assigning to the armor cell according to the probability distribution and it is given by the overall damage distribution matrix D (ie. the one stored in the lookup table, the one we calculate using the normal and uniform distributions) at (i,j) */
    double probmult = D(i,j);
/*now we must compute the pooled armor strength from adjacent cells for the hit strength calculation*/
    double armorpooled=0;
for(int x=0; x < 5; x++){
for(int y=0; y < 5; y++){
armorpooled = armorpooled + std::max(A((i-2)+x,y)/2, a_mod * a/2);
}
}
for(int x=0; x < 3; x++){
for(int y=0; y < 3; y++){
armorpooled = armorpooled + std::max(A((i-1)+x,y+1)/2, a_mod * a/2);
}
}

/* adjusted damage is: maximum of: damage assigned to cell (d = total damage times probmult = fraction of total damage assigned to cell), adjusted by hit strength / (armor + hit strength), or, if that were below it, the threshold armor can't reduce damage below times damage assigned to cell. In addition, because the modifiers are he = 0.5, kinetic = 2, frag = 4, then the correct adjustment for hit strength to hit armor is hit strength / modifier*/
double adjusted_d = std::max(d*h/m/(armorpooled+h/m)*probmult, minimumdamageafterarmorreduction*d*probmult);
/* if we are dealing less damage than either armor hp or minimum armor then we are not dealing hull damage */
    if (adjusted_d <= armor){
/* reduce armor hp by adjusted allocated damage */
      A(i,j) = A(i,j) - adjusted_d;
/* if armor went below 0 set armor to 0.0 since it is a double */
      A(i,j) = std::max(A(i,j), 0.0);
    }
/* if we are dealing more damage than either armor hp or minimum armor */
    if (adjusted_d > armor){
/* set armor cell hp to 0 */
      A(i,j) = 0.0;
/* now calculate the damage to hull. It is adjusted damage minus minimum armor or armor hp, re-scaled to hull. Due to the way we defined the modifier above, we can  re-scale the damage back to the appropriate modifier for hull by multiplying it by m (since all weapons deal 100% damage to hull) */
      adjusted_d = (adjusted_d - armor)*m;
/* this next line might just as well read hd = adjusted_d; */
      hd = hd + adjusted_d;
    }
  }
  }
  return hd;
}

This is certainly not an optimal interpretation of what the code should do, but since you will be programming it in a different way, this should only act as a reference anyway.

Edit to add: for some reason this, however, seems to return far too high times to kill (e.g.

"72"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   ""   "Gauss"   "" when the previous simulation based method returned

"27"   "Squall"   "Locust"   "Harpoon"   "Harpoon"   "Gauss"   "Gauss"   ""   "")

So there is definitely something wrong with this fixed version. Was the previous one right all along? Unfortunately I simply can't put any more time into this just now as I have some calculus coursework where it's going to be less than ideal if I don't turn it in time. Anybody see where the error might be? Or is it lurking somewhere in the rest of the code?

Regardless, I'm confident in the structure of this thing even if there is a computational error somewhere.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 22, 2022, 08:25:18 AM
Anyway, your code seems mostly right, however there is one extra thing which is that hit strength and damage must be two different things. This is because for beams we have hit strength = dps / 2 but damage = beam ticks(adjusted for chargeup/chargedown)*dps/beam tick. We get the adjusted beam ticks from the hit time series function for each second. And, I'm not an expert on armor, but I think Vanshilar said earlier that cells with 0 armor can contribute to minimum armor even if pooled armor is not yet at the minimum armor threshold so that affects the order of sum and max operations. Vanshilar, can you confirm if I have this right?
Good point on the hit strength thing. I wasn't thinking about beams when I wrote the code, but it's easy enough to extend the dmg vector to include both values.

In terms of minimum armor, I'm trying to figure out how minimum armor per cell would work? You would need to multiply the overall minimum armor (based on the base armor) by 1/15 for inner cells and 1/30 for outer cells or something to make it reasonable. For instance, if your base armor is 1000, then each cell has 66.67 starting armor, but your minimum armor would be 50, so you would effectively prevent your armor from going below ~75%. Maybe that is why your code is giving strange results as well?

I also realized that when I cleaned up this function, I made a mistake. I shouldn't be doing the actual hull and armor updates inside the function when I'm handling the expected value case/probabilities because then I would be changing the armor values while iterating through the different possible shot locations. Instead the function should just return the expected damage values to hull/armor, and the outer code can appropriately reduce the armor/hull. In the probabilistic case, that would involve adding up the expected damage contributions from each possible hit location and then subtracting them from the total all at once.

edit: I'm pretty sure the way I was doing minimum armor is right, since I matched results with vanshilar in the last thread. Armor cells don't really 'contribute' to minimum armor. They contribute to pooled armor, and if pooled armor is too low, .05*base armor is used instead. So there is still minimum armor that way, even if all the cells are stripped.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 22, 2022, 08:34:35 AM
I've posted a potential reference implementation in Java on the modding questions thread (https://fractalsoftworks.com/forum/index.php?topic=5061.msg381874#msg381874).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: NuclearStudent on November 22, 2022, 11:01:12 AM
Nick, the Detailed Combat Results guy, is updating his mod in the next few days to have callable methods to output sim data to log. He's open to any suggestions about information to log or features that'd make simming easier.

Link of comms: https://imgur.com/a/LY0fYeV
Link of example datadump: https://drive.google.com/file/d/14CvzqFJ5yHrXAVS1IcY1OoV6lPORaghJ/view?usp=share_link

Figured that you guys might get value from this to compare against a "real" simulation within SS itself, as with Vanshilar's work. I have no doubt that you guys would be better than I am at datascraping and analysis. I discussed this with Liral on the discord earlier this month, maybe we can collab about it in January-ish when I can fully devote my energy to this.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 22, 2022, 07:28:55 PM
I have now run the database code, found it did not quite work, modified it until it did, and ultimately discovered that the .csv or .ship files of some mods have caused bugs: namely Arma Armatura, Gundam Universal Century, and Scy.  Only Low Tech Armada and Practice Targets passed through cleanly.  I have created a duck-tape solution: a function that checks for the ids of these mods and hardcoded a constant tuple of those ids into the database code.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 23, 2022, 04:07:07 AM
Okay so I got my statistics and calculus and so on homework done (if you good folks have never looked at Lebesgue integrals, please do, it is fascinating) and found a bug. Another one is lurking somewhere. Anyway, the C++ code I wrote only applied the modifier vs. armor to hit strength and not damage, when it should be applied to both! Doh! See "d/m*h/m/(armorpooled+h/m)" in the fixed code below.

This gives a shorter time but still 52 seconds to kill the Dominator with Squall - Squall - Harpoon - Harpoon - Gauss - Gauss so something else is still off in the new code. Maybe in the new hit distribution, since I wasn't able to find it in the damage function?

Edit2: I found two more bugs. One was a small one affecting the final cell of the hit distribution which didn't really matter here. But here's the crux of it: we are either NOT supposed to pool armor here, or must apply a modifier later.

Explanation: we are computing damage by cell. Let's make some definitions
h hit strength (of shot)
d damage
d_adj damage adjusted for hit strength
a armor (of ship)
A_ij armor value of cell
A_ij_next next armor value of cell after this shot
m modifier (using the definition that damage to armor is d/m)
x_ij distribution factor of ship armor or damage to cell

Now assume all the max(0,...) etc. junk is correctly applied here so the presentation is relatively clean. We want to compute how much armor is damaged by the hit ie. A_ij_next. Now first let's see what happens when we pool the armor: We take d_adj = d*h/m/(h/m+a). That's what we know to be correct. Then the proportion of damage that hits the cell is d_adj*x_ij and A_ij_next = A_ij - d_adj*x_ij/m. Now let's see what happens when we compute cell by cell: we take d_adj = d*x_ij*h/m*x_ij/(h/m*x_ij+A_ij))%u2020 = d*x_ij*h/m*x_ij/(h/m*x_ij+a*x_ij)=d*x_ij*h/m/(h/m+a). And then A_ij_next = A_ij - d_adj.

So if we were to pool the armor and use a instead of A_ij at %u2020 then we would get the incorrect result. And the way I wrote the damage distribution D (as D(i,j) in the function) is that it includes the distribution factor both for cells and for probability.

Anyway that is why the previous method was right and pooling the armor here was incorrect. Fixed code:
Code
'double damage(NumericMatrix A, NumericMatrix D, double a, double a_mod, double d, double h, double m, double minimumdamageafterarmorreduction){
  double hd = 0;
  int rows_A = A.nrow();
  int cols_A = A.ncol();
  a = a/15;
  for (int j = 0; j < cols_A; j++){
  for (int i = 0; i < rows_A; i++){
    double armor = std::max(A(i,j), a_mod*a);
    double probmult = D(i,j);
    double adjusted_d = std::max(d/m*h/m/(armor+h/m)*probmult, minimumdamageafterarmorreduction*d/m*probmult);
if( adjusted_d <= armor){
      A(i,j) = A(i,j) - adjusted_d;
      A(i,j) = std::max(A(i,j), 0.0);
    }
    if (adjusted_d > armor){
      A(i,j) = 0.0;
      adjusted_d = (adjusted_d - armor)*m;
      hd = hd + adjusted_d;
    }
  }
  }
  return hd;
}
'

Hit distribution:

Code

# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds))) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)+1] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds))) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)+1] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upperbounds[1],0,standard_deviation)
      for (j in 2:(length(upperbounds))) vector[j] <- pnorm(upperbounds[j],0,standard_deviation) - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)+1] <- 1-pnorm(upperbounds[length(upperbounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

With these we get the exact same result as from the simulation based method for Squall-Squall-Harpoon-Harpoon-Gauss-Gauss vs. Dominator (technically 26 vs 27 seconds according to my graph, but we might expect it to be a little faster since we are now also accounting for shield radius).

(https://i.ibb.co/z8wFq3H/image.png) (https://ibb.co/3fHvgxY)

But honestly, you might want to write this whole thing differently using the "whole-armor" definitions and applying the modifier later as there is a high risk of confusion here. If individual cells can't contribute minimum armor when the pooled armor is not below threshold then there is literally no difference.

E3: alright that was confused and confusing even for me, since we are not strictly doing that and the definition of x_ij is not consistent (it is not correct to just insert Dij into hit strength in the algorithm). Here is some scaffolding for a more formal look at this. To be continued.

(https://i.ibb.co/d4xj5b1/image.png) (https://ibb.co/k2v5S3k)
(In your mind please remove the as from D as they are just a result of copy paste and hurry. The damage distribution is then dD where d is shot damage. Then once again ignoring all the inelegant junk like max() and modifiers:

If you imagine that we are looking at the expected value of the hit strength at cell vs. expected value of armor at cell, and h is total hit strength, then you have hDij hit strength vs armor strength aijDij, so hDij/(hDij+aijDij) = h/(h+Aij) as the multiplier for damage and damage dealt is d*Dij*(h/(h+Aij)). Which is exactly what we are doing in the non-pooling function above. And its wrong to look at hit strength hDij vs armor strength sum(5x5 area in the way the game does it))

If it's confusing for even me who wrote it based on this intuitive imagining of ghost armor cells and probability waves, though, then that's probably a good reason to rewrite it with easier formalism but for the time being it does work.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 23, 2022, 10:17:56 AM
I think your problem is that you're trying to do the armor damage reduction for each armor cell individually. That's either wrong or done in an unnecessary way.

You do the damage reduction once to the full shot damage using the pooled armor value, and then divide the adjusted damage amongst the cells using the 1/15 and 1/30 factors. The whole (d/(armor +d)) adjustment should not happen cell-wise.

Edit: maybe I misinterpreted code, not 100% sure what is being done outside of the loop/code snippet being shown, so I think it could theoretically be mathematically correct, but you definitely should not need to do that armor reduction calculation inside a loop over armor cells ever. At the very least, that should be moved outside of any loops over armor cells.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 23, 2022, 10:46:38 AM
Well, it seems to work. I'm having a little bit of trouble imagining the "whole-ship" formalism now that I got the idea, but I can't seem to get it together without coming back to cell by cell computations because of the probability. Because the problem is of course probability, not damage distribution. Could you tell me how you would account for probability to hit a given cell, when doing it in bulk as you describe? What is the proportion of expected damage to a cell when you also have a probability distribution? It would certainly be much better to have simpler math for this as it's pretty confusing.

The main issue is that you have overlapping hits as it were. So how do you pool cells to get the total armor to compare to? Especially given they could have in principle arbitrary armor hp due to previous weapon hits.

Please show the problems in the matrix version of the above if you see some and I'll try to fix. Ie: looking at the matrices I posted above (and removing the a_ijs from D) then E(damage to cell) = Dij*damage, and 'If you imagine that we are looking at the expected value of the hit strength at cell vs. expected value of armor at cell, and h is total hit strength, then you have hDij hit strength vs armor strength aijDij, so hDij/(hDij+aijDij) = h/(h+Aij) as the multiplier for damage and damage dealt is d*Dij*(h/(h+Aij)). Which is exactly what we are doing in the non-pooling function above. And its wrong to look at hit strength hDij vs armor strength sum(5x5 area in the way the game does it))'

Now we had a discussion about whether cell by cell produces the corrected expected value in the last thread and at the very least Vanshilar found by simulating it that the error is at the most small IIRC, although the matter was finally left unsettled.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 23, 2022, 12:27:16 PM
Well, it seems to work. I'm having a little bit of trouble imagining the "whole-ship" formalism now that I got the idea, but I can't seem to get it together without coming back to cell by cell computations because of the probability. Because the problem is of course probability, not damage distribution. Could you tell me how you would account for probability to hit a given cell, when doing it in bulk as you describe? What is the proportion of expected damage to a cell when you also have a probability distribution? It would certainly be much better to have simpler math for this as it's pretty confusing.

The main issue is that you have overlapping hits as it were. So how do you pool cells to get the total armor to compare to? Especially given they could have in principle arbitrary armor hp due to previous weapon hits.

Please show the problems in the matrix version of the above if you see some and I'll try to fix. Ie: looking at the matrices I posted above (and removing the a_ijs from D) then E(damage to cell) = Dij*damage, and 'If you imagine that we are looking at the expected value of the hit strength at cell vs. expected value of armor at cell, and h is total hit strength, then you have hDij hit strength vs armor strength aijDij, so hDij/(hDij+aijDij) = h/(h+Aij) as the multiplier for damage and damage dealt is d*Dij*(h/(h+Aij)). Which is exactly what we are doing in the non-pooling function above. And its wrong to look at hit strength hDij vs armor strength sum(5x5 area in the way the game does it))'

Now we had a discussion about whether cell by cell produces the corrected expected value in the last thread and at the very least Vanshilar found by simulating it that the error is at the most small IIRC, although the matter was finally left unsettled.

I want to know if I understand what we're doing, so here's my best understanding.  We have a horizontal row of armor cells A across which we want to compute the probable damage of one shot from some weapon.  First, pad this row with three ghost armor cells above, below, and to the left and right to form an armor grid G.  Next, for the ith cell from the left of A, ai, generate such a separate grid of ghost armor cells as follows:

111
12221
12221
12221
 111


Calculate and distribute the damage of one hit from the weapon in question across each grid and save the resulting hull damage in a corresponding row H.  Then, for each grid, for each cell, multiply the difference of the cell value from that of the corresponding cell in G by the probability of hitting ai and subtract the product from the cell in G.

Do I understand correctly?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 23, 2022, 01:08:41 PM
You have a set of possible events (locations the shot can land) along with the damage that occurs (over multiple cells) if the shot lands there (a function of the shot location). The expected value of a function of a discrete random variable is just  E[f(x)] = sum(f(x) * p(x)) over all values of x. So in this case x is the shot location, and if you consider the armor cell the shot landed on as the random variable, then it is a discrete random variable. Then f(x) is just the normal damage calculations. The hard part is figuring out the probability distribution of that random variable p(x) based on the underlying distribution of possible shot angles, but I think we already did that.

Then on a high level the code should be:

loop over possible shot locations

calculate damage to the entire armor array and hull given that the shot landed in that particular location (damage|location) and store the values

end loop

expected_damage = sum(damage|location*p(location)) (again both hull and armor cell values)

I guess you could do the probability multiplication inside the loop and sum as you go to get the total expected damage, and then subtract the total expected damage off of the armor values and hull value at the end of the loop. It's very important to not subtract the armor damage from the armor values mid loop though, because you need to use the pre-shot armor values for the entire loop.

Edit, maybe the confusion is that inside that f(x) function, there is another loop through armor cells? That is the loop that should not contain the (d/(d+a)) expression. The outer loop (over shot locations, which is also a loop over armor cells) obviously has to contain all the damage calculations. Maybe you were trying to do it all in a single loop? I'm not sure if that is possible.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 23, 2022, 05:23:51 PM
You have a set of possible events (locations the shot can land) along with the damage that occurs (over multiple cells) if the shot lands there (a function of the shot location). The expected value of a function of a discrete random variable is just  E[f(x)] = sum(f(x) * p(x)) over all values of x. So in this case x is the shot location, and if you consider the armor cell the shot landed on as the random variable, then it is a discrete random variable. Then f(x) is just the normal damage calculations. The hard part is figuring out the probability distribution of that random variable p(x) based on the underlying distribution of possible shot angles, but I think we already did that.

Then on a high level the code should be:

loop over possible shot locations

calculate damage to the entire armor array and hull given that the shot landed in that particular location (damage|location) and store the values

end loop

expected_damage = sum(damage|location*p(location)) (again both hull and armor cell values)

I guess you could do the probability multiplication inside the loop and sum as you go to get the total expected damage, and then subtract the total expected damage off of the armor values and hull value at the end of the loop. It's very important to not subtract the armor damage from the armor values mid loop though, because you need to use the pre-shot armor values for the entire loop.

Edit, maybe the confusion is that inside that f(x) function, there is another loop through armor cells? That is the loop that should not contain the (d/(d+a)) expression. The outer loop (over shot locations, which is also a loop over armor cells) obviously has to contain all the damage calculations. Maybe you were trying to do it all in a single loop? I'm not sure if that is possible.

I think we agree and must now vectorize the necessary operations for NumPy to execute quickly.   I want to show you an example of what we have to work with.  This code initializes the armor cell row, encompassing armor grid, and virtual armor grid corresponding to each cell on the armor cell row.

import numpy as np

armor_per_cell = 100
armor_row_width = 10
armor_row = np.full(armor_row_width, armor_per_cell)
print("armor row")
print(armor_row, "\n")

padding = 2
armor_grid_width = armor_row_width + 2 * padding
armor_grid_height = 1 + 2 * padding
armor_cell_ids = np.arange(armor_grid_width * armor_grid_height)
armor_grid = np.reshape(armor_cell_ids, (armor_grid_width, armor_grid_height))
print("armor grid")
print(armor_grid, "\n")

virtual_armor_grids = np.array(
    [armor_grid[i - padding - 1 : i + padding] for i in
    range(padding + 1, armor_row_width + padding + 1)]
)
print("virtual armor grids")
print(virtual_armor_grids)
output
armor row
[100 100 100 100 100 100 100 100 100 100]

armor grid
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]
 [15 16 17 18 19]
 [20 21 22 23 24]
 [25 26 27 28 29]
 [30 31 32 33 34]
 [35 36 37 38 39]
 [40 41 42 43 44]
 [45 46 47 48 49]
 [50 51 52 53 54]
 [55 56 57 58 59]
 [60 61 62 63 64]
 [65 66 67 68 69]]

virtual armor grids
[[[ 0  1  2  3  4]
  [ 5  6  7  8  9]
  [10 11 12 13 14]
  [15 16 17 18 19]
  [20 21 22 23 24]]

 [[ 5  6  7  8  9]
  [10 11 12 13 14]
  [15 16 17 18 19]
  [20 21 22 23 24]
  [25 26 27 28 29]]

 [[10 11 12 13 14]
  [15 16 17 18 19]
  [20 21 22 23 24]
  [25 26 27 28 29]
  [30 31 32 33 34]]

 [[15 16 17 18 19]
  [20 21 22 23 24]
  [25 26 27 28 29]
  [30 31 32 33 34]
  [35 36 37 38 39]]

 [[20 21 22 23 24]
  [25 26 27 28 29]
  [30 31 32 33 34]
  [35 36 37 38 39]
  [40 41 42 43 44]]

 [[25 26 27 28 29]
  [30 31 32 33 34]
  [35 36 37 38 39]
  [40 41 42 43 44]
  [45 46 47 48 49]]

 [[30 31 32 33 34]
  [35 36 37 38 39]
  [40 41 42 43 44]
  [45 46 47 48 49]
  [50 51 52 53 54]]

 [[35 36 37 38 39]
  [40 41 42 43 44]
  [45 46 47 48 49]
  [50 51 52 53 54]
  [55 56 57 58 59]]

 [[40 41 42 43 44]
  [45 46 47 48 49]
  [50 51 52 53 54]
  [55 56 57 58 59]
  [60 61 62 63 64]]

 [[45 46 47 48 49]
  [50 51 52 53 54]
  [55 56 57 58 59]
  [60 61 62 63 64]
  [65 66 67 68 69]]]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 23, 2022, 08:34:14 PM
Well, it seems to work. I'm having a little bit of trouble imagining the "whole-ship" formalism now that I got the idea, but I can't seem to get it together without coming back to cell by cell computations because of the probability. Because the problem is of course probability, not damage distribution. Could you tell me how you would account for probability to hit a given cell, when doing it in bulk as you describe? What is the proportion of expected damage to a cell when you also have a probability distribution? It would certainly be much better to have simpler math for this as it's pretty confusing.

The main issue is that you have overlapping hits as it were. So how do you pool cells to get the total armor to compare to? Especially given they could have in principle arbitrary armor hp due to previous weapon hits.

Please show the problems in the matrix version of the above if you see some and I'll try to fix. Ie: looking at the matrices I posted above (and removing the a_ijs from D) then E(damage to cell) = Dij*damage, and 'If you imagine that we are looking at the expected value of the hit strength at cell vs. expected value of armor at cell, and h is total hit strength, then you have hDij hit strength vs armor strength aijDij, so hDij/(hDij+aijDij) = h/(h+Aij) as the multiplier for damage and damage dealt is d*Dij*(h/(h+Aij)). Which is exactly what we are doing in the non-pooling function above. And its wrong to look at hit strength hDij vs armor strength sum(5x5 area in the way the game does it))'

Now we had a discussion about whether cell by cell produces the corrected expected value in the last thread and at the very least Vanshilar found by simulating it that the error is at the most small IIRC, although the matter was finally left unsettled.

I want to know if I understand what we're doing, so here's my best understanding.  We have a horizontal row of armor cells A across which we want to compute the probable damage of one shot from some weapon.  First, pad this row with three ghost armor cells above, below, and to the left and right to form an armor grid G.  Next, for the ith cell from the left of A, ai, generate such a separate grid of ghost armor cells as follows:

111
12221
12221
12221
 111


Calculate and distribute the damage of one hit from the weapon in question across each grid and save the resulting hull damage in a corresponding row H.  Then, for each grid, for each cell, multiply the difference of the cell value from that of the corresponding cell in G by the probability of hitting ai and subtract the product from the cell in G.

Do I understand correctly?

Pretty much but here's the beef of it, I'll try to be as informal as possible:

Let's say that we have a line of n armor cells. Then, for each of the n armor cells, compute probability to hit that cell. Call this p_k.

Now, create a new matrix that is of width k and padded with 2 cells above, below, left and right. Since armor is pooled this way in Starsector that we even have virtual cells outside hittable cells, this represents the armor matrix the shots see. For the first iteration set each cell to starting armor/15, then later keep it as whatever it is after armor takes damage.

Now, how does this armor take damage?

If we were to it through loops we must calculate, for each cell, at the topmost cells, 1/30th of the damage FOR each of the up to three shots that can damage the cell multiplied by the probability of each of those hits separately (e.g. for a middle cell k for the top and bottom row, 1/30p_(k-1)+1/30p_k+1/30p_(k+1).). For the middle rows, 1/15 damage for each of the up to three shots where this cell can be a middle cell and 1/30 damage for each of the up to two shots where this cell can be an edge cell, multiplied by probability of each hit separately. Then loop over armor.

What my code does instead is pre-compute the probability and damage distribution for each weapon into the damage distribution matrix D (the one we store in the lookup table for each weapon at each timepoint) so the loop is just damage multiplied by damage distribution matrix for each cell.

Now the other consideration is hit strength and if we are doing it using this method then you do not pool the armor but use the matrix D also (the expected value of hit strength to that cell) and the armor at that cell (times D_cell for the expected value of the armor that the shot is "seeing"). Specifically 1/15 the hit strength for each of the up to three shots where the cell can be a central cell times the probability of those shots plus 1/30 the hit strength for each of the up to three shots where the cell can be a peripheral cell times probability versus armor of 1/15 for each of the up to three shots where the cell can be a central cell times the probability of those shots plus 1/30 the hit strength for each of the up to three shots where the cell can be a peripheral cell times probability as this is how the armor would be pooled to meet the shot.

Doing the hit strength calc in aggregate is what the game does. But then using the probabilistic method and not doing shots one by one, the question is what is the armor for the hit strength calculation so that the damage distribution calculation remains accurate, if we were to do that? Because each cell is hit up to 5 times with different fractions of a shot rather than once but we only have 1 step where we distribute the damage. I don't have an answer at this time and seems non trivial, you may want to pen and paper this out.

For a very easily imaginable example of the problem with aggregate hit strength calculations when we have probabilistic shots, a shot with very narrow spread is going to "see" different cells than one with very wide spread, so how do you account for that in the hit strength calculation when the armor is damaged and is not uniform, if doing it in aggregate? In this example for example it would be wrong to use whole ship armor for both shots for the hit strength calculation without any adjustment - if you doubt this, then consider that for a gun, hit strength and damage are equal, but were you to do this then they would be spread differently. It is also not correct to calculate hit strength for each middle cells and subtract damage immediately as then we have order of operations affecting damage (the previous subtraction reduces armor for the next hit strength calculation). It should be correct to calculate it for each middle cell pooling the surroundings, saving the value somewhere else, then distributing all the damage according to the D matrix in one step (ie. 1/30 of the damage for peripheral hits and 1/15 for central hits times probability of each hit separately). But then you are doing the exact same thing as the cell by cell, just in a more complicated order. Doing it cell by cell removes this issue.

I've got a hunch that pooling the armor to account for the problem above is going to involve the same matrix as distributing the hit strength and it's going to make no difference, because this is what imagining things the shot sees tells me, but haven't done the pen and paper math.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 23, 2022, 09:49:48 PM
I think, you're making this way harder than it has to be. I really don't understand what's wrong with looping over shot locations. It works fine.


I ported my code into python, and verified it produces the same results as my MATLAB code. It probably could be optimized but it's reasonably fast. A quick test found that calculating the expected damage of one shot took ~.00064 seconds (averaged over 10000 trials) on my laptop (2.3 GHz i9).

Code
import numpy as np


def getArmorIndexes(location, armor_grid):

    inner_cells = np.full(armor_grid.shape, False)
    outer_cells = np.full(armor_grid.shape, False)

    if any(np.less_equal(location, 1)) | location[0] > armor_grid.shape[0] - 3 | location[1] > armor_grid.shape[1] - 3:
        raise Exception("hit location must not be on the edge of the grid")

    inner_cells[location[0] - 1: location[0] + 2, location[1] - 1: location[1] + 2] = True

    outer_cells[location[0] - 2, location[1] - 1: location[1] + 2] = True
    outer_cells[location[0] + 2, location[1] - 1: location[1] + 2] = True
    outer_cells[location[0] - 1: location[0] + 2, location[1] - 2] = True
    outer_cells[location[0] - 1: location[0] + 2, location[1] + 2] = True
   
    return inner_cells, outer_cells


def armorUpdate(dmg, location, armor_grid, minimum_armor):
    # dmg is a vector with elements:
    # [shield_damage, armor_damage, hull_damage, shield_hit_strength, armor_hit_strength, hull_hit_strength]
    # location is a tuple of the coordinates in the armor grid where the shot lands
    # armor_grid is an array with each element corresponding to an armor cell
    # minimum armor is the smallest armor value the pooled armor is allowed to take. Typically .05*max_armor

    inner_cells, outer_cells = getArmorIndexes(location, armor_grid)

    armor_pooled = np.maximum(np.sum(armor_grid[inner_cells]) + 1 / 2 * np.sum(armor_grid[outer_cells]), minimum_armor)

    armor_damage_reduction = np.maximum(dmg[4] / (dmg[4] + armor_pooled), .15)

    damage_armor = (1 / 15 * inner_cells + 1 / 30 * outer_cells) * armor_damage_reduction * dmg[1]

    damage_hull = np.maximum(damage_armor - armor_grid, 0) * dmg[2] / dmg[1]

    damage_hull_total = np.sum(damage_hull)

    return damage_armor, damage_hull_total


def expectedArmorDamage(locations, location_probabilities, dmg, armor_grid, minimum_armor):

    damage_armor_expected = np.zeros(armor_grid.shape)
    damage_hull_expected = 0

    for i, location in enumerate(locations):
        damage_armor, damage_hull = armorUpdate(dmg, location, armor_grid, minimum_armor)
        damage_armor_expected += damage_armor * location_probabilities[i]
        damage_hull_expected += damage_hull * location_probabilities[i]

    return damage_armor_expected, damage_hull_expected


Lmao, as soon as I looked at my code, I noticed optimizations I could do. For instance, I pass in the entire armor_grid array to the getArmorIndexes function when I only need to pass in the size of the array. There's also quite a few unnecessary intermediate variables, but I kinda like the improved readability.

Also, just to summarize what the code does. The first function (getArmorIndexes) takes shot location coordinates and the armor grid (should just be the size of the armor grid) and returns boolean arrays of the same size as the armor grid with all the inner cells or outer cells set to 'true' and all others set to false. That's super convenient for doing calculations.

The second function (armorUpdate) takes the initial armor grid, the damage of the shot, the location the shot lands, and the minimum armor value, and returns the damage dealt to each armor cells as well as the total hull damage dealt.

The last function expectedArmorDamage takes all the arguments of armorUpdate, except the shot location is replaced by the set of possible shot locations and the associate probabilities. It returns the expected damage to each armor cell and the expected hull damage.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 23, 2022, 10:27:40 PM
Well, here's a better look at this:

(https://i.ibb.co/JtvMzp1/image.png) (https://ibb.co/H4tkrPS)

I can't read that code very easily, so I think the easiest way to show that it is correct is that you apply your code to calculate times to kill vs ships and show that it produces reasonable results in practice. Because it's honestly not very easy to see when you are falling into the trap of applying the incorrect (non-probability-adjusted) armor strength for the hit strength calculation even when it's your own code, at least for me.

If it's still hard to see where the danger lies basically just imagine that you have 1 weapon (weapon A) that repeatedly hits the center of the armor and 1 weapon (weapon B) that hits uniformly over the armor. Now if you are applying the whole armor for the damage reduction then you will get correct results for weapon B but incorrect results for weapon A after shot 1, because weapon A doesn't "see" the peripheral cells ever so you are reducing its damage too much. Weapon A doesn't "see" the cells right outside of the cells it damages either so when you are applying damage to outermost cells that weapon A damages then you still are not allowed to include armor values from outside weapon A's "vision" in the hit strength calculation.

I mean if you avoid that trap and have a simple way of computing it that's great but I certainly fell right into that trap when I just naively applied pooling and then got obviously incorrect results when I tried to use that for the time to kill.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 23, 2022, 10:43:09 PM
I already verified that my MATLAB code matched the TTKs from vanshilars excel speed sheet way back when we started all of this (in the other thread when we discussing movement based inaccuracy and distributions). I also verified that the EVs from my MATLAB code were reasonably close to the averaged damage values from large numbers of deterministic trials. All I did today was port the code into python and verify that it produces the same results as the MATLAB code.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 23, 2022, 10:47:04 PM
Alright that's great then. Then use that code as it's simpler and let's see the results we get when it's all put together. I must have been missing something very elementary, wouldn't be the first time. Can you just plug the hit probability distribution code into location_probabilities?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 24, 2022, 12:16:58 AM
Yeah, the location_probabilities can come from whatever distribution you want. As long as you have a list of all the possible shot locations and the associated probabilities, everything else is pretty straight forward.

I will say that this way of calculating EV is not perfectly correct for a sequence of shots. I've verified that it's perfectly accurate for one shot. At least, I verified that the expected armor damage of a single shot is accurate to the average damage of one shot over 10k monte carlo trials. There was on the order of 1e-12 error, so basically to within numerical error. I haven't bothered to cook up a test case where hull damage comes into play in one single shot.

But for a sequence of shots, there is decent amount of error due to the fact that you are essentially using the EV of the armor grid from the previous calculation/shot to compute the Ev of the next armor values, which is not strictly correct. I actually went on about this a while back when we were initially discussing distributions. There is a somewhat significant amount of error introduced due to this. In a quick test case with 10000 hull and 1000 armor vs 400 energy damage, the error between the expected hull values and the average hull from 10k monte carlo runs maxed out at a few hundred hull points. So on the order of 5% error. The number of shots to kill though was fairly close and stable.

Edit: forgot to mention this is all for a pretty wide uniform distribution of shots because I am lazy and just wanted to test things. Not sure how different distributions will change things. A very quick test found that the magnitude of the error seemed to decrease approximately proportionally with the width of the uniform distribution, so as the shots get tighter, there is less error.

Some fun plots to highlight this:
Spoiler
(https://i.imgur.com/us6FYJ7.png)

(https://i.imgur.com/7aSjcM0.png)

You can see in the first figure that the dark black line (the EV) is somewhat off from the average of the red dashed lines (the MC trials). In the second figure, I plotted the error between the EV and the average of the MC trials.
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 01:18:13 AM
If you think this is correct:
(https://i.ibb.co/4smgxGq/image.png) (https://ibb.co/VHjp8Z1)

and we have a wide uniform distribution, then the calculation for the aggregate exact expected value becomes pretty easy as p_1=p_2=p_3...=p_n and then sum_j sum_i D_ij A_ij is exactly equivalent to p*sum_j sum_i X_ij, so if you have a wide uniform distribution then it is equivalent to calculating it in aggregate at the midpoint of each cell and then distributing the damage. Likewise if you have an extremely narrow distribution then for some k you would have p_1=p_2=...=p_{k-1}=p_{k+1}=...=p_n=0 and p_k = 1. And then you get sum_j sum_i D_ij A_ij = sum_j sum_i A_ij when the middle cell is k so the damage around middle cell k is accurate to calculate in aggregate and then distribute (for the first shot, anyway). The problem is specifically the arbitrary case of non-trivial probability distributions and arbitrary locations on the armor.

However, if it's a small error, then of course it might be reasonable to just ignore it. On the other hand the cell by cell calculation I posted above seems to do it fast too and computes the above matrix thing.

Well, it's probably going to be fine either way based on your simulations so just implement one way and fix if needed.

Incidentally here is the error of this
(https://i.ibb.co/BjyFGHN/image.png) (https://ibb.co/ct6VwKL)

We should also be able to set some upper bounds on epsilon but I didn't post it as I'm a little uncertain on it but it appears it is small? (from

(https://i.ibb.co/ySzQwgV/image.png) (https://ibb.co/gV0R5t6)

and noting that at least K_ij > 0 in this case, (h/(x+h))''=2h/(x+h)^3 and sigma^2 is less than (A_ij/2)^2 - I'll admit to being out of my depth here for the time being so anybody who knows this stuff, chime in. Numerically I tried setting A_ij ~ N(a/30, (a/30)^2) (ie armor is almost completely random) with an upper bound of a/30 and lower bound of 0, and I found that running hit strength from 10 to 1000 in increments of 10 and ship armor from 0 to 1750 in increments of 50, then with 100 samples from each combination of armor and hit strength the maximum upper bound for epsilon in the worst case scenario (as max(sigma^2(h/(x+h)''/2)) observed in the dataset was 0.0658 and the average upper bound for epsilon was 0.0028 (ie model underestimates real expected value damage between arbitrary consecutive timepoints by at most 0.28% on average, but with a particularly crude worst-case estimation of the upper bound).

(https://i.ibb.co/0K3rPS6/image.png)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 24, 2022, 07:44:58 AM
Thanks for confirming, CapnHector.  I hope this code reflects my understanding accurately.

import numpy as np

PADDING = 2

MAXIMUM_DAMAGE_REDUCTION = 0.85

MINIMUM_ARMOR_FACTOR = 0.05

POOLING_FACTORS = 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]
])


def padded_grid(row_width):
    """
    Return a padded grid around a row of some width.
    """
    grid_width = row_width + 2 * PADDING
    grid_height = 1 + 2 * PADDING
    #cell_ids are just so you, the reader, can tell the cells apart
    #I will replace them with armor_rating / 15
    cell_ids = np.arange(grid_width * grid_height, dtype = 'float64')
    return np.reshape(cell_ids, (grid_width, grid_height))


def square_grids(padded_grid):
    """
    Return one square grid for each cell along the middle
    row of a padded grid, less the padding to either side.
    """
    return np.array([padded_grid[i - PADDING - 1 : i + PADDING] for i in
                    range(PADDING + 1, len(padded_grid) - 1)])


def pool(armor_grids):
    """
    Return the pooled armor value of the cells of each
    of several armor grids.
    """
    return np.array([np.sum(POOLING_FACTORS * grid) for grid in armor_grids])


def armor_damage_factor(hit_strength, pooled_armor):
    """
    Return the factor multiplying the damage of a hit to
    armor cells.
    """
    damage_factor = hit_strength / (hit_strength + pooled_armor)
    damage_factor[np.less(MAXIMUM_DAMAGE_REDUCTION, damage_factor)] = (
        MAXIMUM_DAMAGE_REDUCTION)
    return damage_factor
   
   
def hull_damage_factor(hit_strength, pooled_armor, armor_rating):
    """
    Return the factor multiplying the damage of a hit to
    the hitpoints of a ship.
    """
    pooled_armor[np.less(pooled_armor, armor_rating * MINIMUM_ARMOR_FACTOR)] = (
        armor_rating * MINIMUM_ARMOR_FACTOR)
    return hit_strength / (hit_strength + pooled_armor)


def armor_damage(hit_strength, pooled_armor):
    """
    Return the maximum damage to a hit inflicts upon
    cells of a pooled armor value.
    """
    return hit_strength * armor_damage_factor(hit_strength, pooled_armor)
   
   
def hull_damage(hit_strength, pooled_armor, armor_rating):
    """
    Return the damage a hit inflicts upon the hitpoints of a ship.
    """
    return hit_strength * hull_damage_factor(hit_strength, pooled_armor,
                                            armor_rating)


def main():
    """
    Demonstrate the above functions.
    """
    #target
    hull = 1000
    armor_rating = 500
    armor_per_cell = 100
    armor_row_width = 10
   
    #shooter
    hit_probabilities = np.array([0.1 for _ in range(armor_row_width)])
    hit_strength = 100
   
    armor_row = np.full(armor_row_width, armor_per_cell)
    print("armor row")
    print(armor_row, "\n")
   
    armor_grid = padded_grid(armor_row_width)
    print("armor grid")
    print(armor_grid, "\n")
   
    virtual_armor_grids = square_grids(armor_grid)
    print("virtual armor grids")
    print(virtual_armor_grids,"\n")

    pooled_armor = pool(virtual_armor_grids)
    print("pooled armor")
    print(pooled_armor, "\n")
   
    damage_to_armor = armor_damage(hit_strength, pooled_armor)
    print("damage to armor")
    print(damage_to_armor, "\n")
   
    damage_to_hull = hull_damage(hit_strength, pooled_armor, armor_rating)
    print("damage to hull")
    print(damage_to_hull, "\n")
   
main()
output
armor row
[100 100 100 100 100 100 100 100 100 100]

armor grid
[[ 0.  1.  2.  3.  4.]
 [ 5.  6.  7.  8.  9.]
 [10. 11. 12. 13. 14.]
 [15. 16. 17. 18. 19.]
 [20. 21. 22. 23. 24.]
 [25. 26. 27. 28. 29.]
 [30. 31. 32. 33. 34.]
 [35. 36. 37. 38. 39.]
 [40. 41. 42. 43. 44.]
 [45. 46. 47. 48. 49.]
 [50. 51. 52. 53. 54.]
 [55. 56. 57. 58. 59.]
 [60. 61. 62. 63. 64.]
 [65. 66. 67. 68. 69.]]

virtual armor grids
[[[ 0.  1.  2.  3.  4.]
  [ 5.  6.  7.  8.  9.]
  [10. 11. 12. 13. 14.]
  [15. 16. 17. 18. 19.]
  [20. 21. 22. 23. 24.]]

 [[ 5.  6.  7.  8.  9.]
  [10. 11. 12. 13. 14.]
  [15. 16. 17. 18. 19.]
  [20. 21. 22. 23. 24.]
  [25. 26. 27. 28. 29.]]

 [[10. 11. 12. 13. 14.]
  [15. 16. 17. 18. 19.]
  [20. 21. 22. 23. 24.]
  [25. 26. 27. 28. 29.]
  [30. 31. 32. 33. 34.]]

 [[15. 16. 17. 18. 19.]
  [20. 21. 22. 23. 24.]
  [25. 26. 27. 28. 29.]
  [30. 31. 32. 33. 34.]
  [35. 36. 37. 38. 39.]]

 [[20. 21. 22. 23. 24.]
  [25. 26. 27. 28. 29.]
  [30. 31. 32. 33. 34.]
  [35. 36. 37. 38. 39.]
  [40. 41. 42. 43. 44.]]

 [[25. 26. 27. 28. 29.]
  [30. 31. 32. 33. 34.]
  [35. 36. 37. 38. 39.]
  [40. 41. 42. 43. 44.]
  [45. 46. 47. 48. 49.]]

 [[30. 31. 32. 33. 34.]
  [35. 36. 37. 38. 39.]
  [40. 41. 42. 43. 44.]
  [45. 46. 47. 48. 49.]
  [50. 51. 52. 53. 54.]]

 [[35. 36. 37. 38. 39.]
  [40. 41. 42. 43. 44.]
  [45. 46. 47. 48. 49.]
  [50. 51. 52. 53. 54.]
  [55. 56. 57. 58. 59.]]

 [[40. 41. 42. 43. 44.]
  [45. 46. 47. 48. 49.]
  [50. 51. 52. 53. 54.]
  [55. 56. 57. 58. 59.]
  [60. 61. 62. 63. 64.]]

 [[45. 46. 47. 48. 49.]
  [50. 51. 52. 53. 54.]
  [55. 56. 57. 58. 59.]
  [60. 61. 62. 63. 64.]
  [65. 66. 67. 68. 69.]]]

pooled armor
[180. 255. 330. 405. 480. 555. 630. 705. 780. 855.]

damage to armor
[35.71428571 28.16901408 23.25581395 19.8019802  17.24137931 15.26717557
 13.69863014 12.42236025 11.36363636 10.47120419]

damage to hull
[35.71428571 28.16901408 23.25581395 19.8019802  17.24137931 15.26717557
 13.69863014 12.42236025 11.36363636 10.47120419]

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 08:27:38 AM
Well, a big issue is I can't really read the code. But it seems like what you are doing is pooling the armor, which you shouldn't do if you go cell by cell as I argued above and the C++ function of mine that gave the correct result doesnt. Apologies, it seemed so plausible you should that I got sucked in, wrote it wrong and got obviously wrong results, and retracted and wrote the latex to show why.

Essentially I suggest you pre-compute the matrix D that I described above in the latex posts (this seems complicated maybe but it's actually just what we started with when I first did this, do overlapping sums of the pooling factors matrix multiplied by probability to hit center cell over the padded matrix) and then just go cell by cell so you assign damage and hit strength multiplied by the matrix D for each cell. No pooling and no distributing as those are included in the matrix. The error should be small like calculated and this removes the inaccuracy from pooling without adjusting for probability.

(The exact calculation that should be performed for each cell is in the latex after [C_ij]=...)

And then hull damage comes from each cell the usual way, min(0, damage -max(armor,minarmor)) adjusted for modifier again without any pooling.

Here is the algorithm in plain English
1. Compute the damage distribution matrix. Ie: create a matrix padded with 2 cells in each direction around the ship 's cells that are in a line. Set it to 0.
2. To this matrix sum: for each of the central cells, sum the 5*5 pooling matrix (0, 1/30, 1/30, 1/30, 0\\ 1/30, 1/15... one) multiplied by probability to hit the cell, centered at that cell. This is the damage distribution matrix D and it must be calculated separately for all probability distributions.
3. Now initialize the ship's armor as a matrix of the same size filled with 1/15 of ship armor. Preparations done.

To damage cells:
For each cell of the armor matrix, calculate armor hit strength reduction as shot hit strength / (shot hit strength + armor value in cell) (no pooling). Apply the minimum armor rule here for when cell armor hp is less than 5%. Deal to the cell amount of damage that is full shot damage * the armor reduction you calculated in the previous step * value in matrix D at that same location, adjusting for maximum armor damage reduction. If the damage dealt is more than the armor hp, then also deal hull damage, which is the difference. Note that this description does not include modifiers, but include them in all parts of the calculation including for hit strength, for damage to armor and for hull damage. For hit strength and damage to armor use the modifier vs armor and re-scale back to raw damage for the hull damage. Also make sure armor doesn't go below zero so set it back to 0 as necessary.


This works because pooling is included in D_ij and the above algorithm does it appropriately. So that's it. It's much easier to write in code than justify mathematically or explain and it produces the correct result.

Edit: added a missing point about*armor reduction
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 24, 2022, 09:35:08 AM
Well, a big issue is I can't really read the code. But it seems like what you are doing is pooling the armor, which you shouldn't do if you go cell by cell as I argued above and the C++ function of mine that gave the correct result doesnt. Apologies, it seemed so plausible you should that I got sucked in, wrote it wrong and got obviously wrong results, and retracted and wrote the latex to show why.

Wait, you retracted armor pooling?  I can't find it. :(  I admit I haven't been reading as closely as I perhaps should have because you (as I do) often change your post after making it or later post another one correcting what you wrote.  I've been waiting for you to settle down on your best idea. :D

Quote
Essentially I suggest you pre-compute the matrix D that I described above in the latex posts (this seems complicated maybe but it's actually just what we started with when I first did this, do overlapping sums of the pooling factors matrix multiplied by probability to hit center cell over the padded matrix) and then just go cell by cell so you assign damage and hit strength multiplied by the matrix D for each cell. No pooling and no distributing as those are included in the matrix. The error should be small like calculated and this removes the inaccuracy from pooling without adjusting for probability.

(The exact calculation that should be performed for each cell is in the latex after [C_ij]=...)

And then hull damage comes from each cell the usual way, min(0, damage -max(armor,minarmor)) adjusted for modifier again without any pooling.

Here is the algorithm in plain English

I will try to step through this algorithm by hand for a row that is five cells wide, an armor rating of 150, and hit strength of 10.

Quote
1. Compute the damage distribution matrix. Ie: create a matrix padded with 2 cells in each direction around the ship 's cells that are in a line. Set it to 0.

row
10 10 10
damage distribution matrix
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0


Quote
2. To this matrix sum: for each of the central cells, sum the 5*5 pooling matrix (0, 1/30, 1/30, 1/30, 0\\ 1/30, 1/15... one) multiplied by probability to hit the cell, centered at that cell. This is the damage distribution matrix D and it must be calculated separately for all probability distributions.

I don't know exactly what this means but will guess. Also, why are we applying probability before the damage distribution?  Should we not do some sort of complicated average later instead?  I wonder if it'd be equivalent, ultimately.  Anyhow...

pooling matrix
0    1/30 1/30 1/30 0     
1/30 1/15 1/15 1/15 1/30
1/30 1/15 1/15 1/15 1/30
1/30 1/15 1/15 1/15 1/30
0    1/30 1/30 1/30 0     
sum of pooling matrix
9/15 + 12/30 = 9/15 + 6/15 = 15/15 = 1
probabilities times sum
[p1, p2, p3] * 1 = [p1 * 1, p2 * 1, p3 * 1] = [p1, p2, p3]
Surely this isn't what you meant. 

Quote
3. Now initialize the ship's armor as a matrix of the same size filled with 1/15 of ship armor. Preparations done.

Of what size, exactly?  Row padded on all four sides?

150 ship armor rating / 15th of armor rating per cell = 10 armor per cell

ship armor
10 10 10 10 10 10 10
10 10 10 10 10 10 10
10 10 10 10 10 10 10
10 10 10 10 10 10 10
10 10 10 10 10 10 10


Quote
To damage cells:
For each cell of the armor matrix, calculate armor hit strength reduction as shot hit strength / (shot hit strength + armor value in cell) (no pooling).

10 hit strength / (10 hit strength + 10 armor in cell) = 1/2 hit strength factor

1/2 1/2 1/2 1/2 1/2 1/2 1/2
1/2 1/2 1/2 1/2 1/2 1/2 1/2
1/2 1/2 1/2 1/2 1/2 1/2 1/2
1/2 1/2 1/2 1/2 1/2 1/2 1/2
1/2 1/2 1/2 1/2 1/2 1/2 1/2


Quote
Apply the minimum armor rule here for when cell armor hp is less than 5%.

Wait, isn't that just for hull damage calculation?

Quote
Deal to the cell amount of damage that is full shot damage * value in matrix D at that same location, adjusting for maximum armor damage reduction.

If the damage dealt is more than the armor hp, then also deal hull damage, which is the difference. Note that this description does not include modifiers, but include them in all parts of the calculation including for hit strength, for damage to armor and for hull damage. For hit strength and damage to armor use the modifier vs armor and re-scale back to raw damage for the hull damage. Also make sure armor doesn't go below zero so set it back to 0 as necessary.

I'm lost at this point because of problems before. :(

Quote
This works because pooling is included in D_ij and the above algorithm does it appropriately. So that's it. It's much easier to write in code than justify mathematically or explain and it produces the correct result.

Edit: added a missing point about*armor reduction

Then write it in code, perhaps pure C++? :)  I would happily translate it to NumPy.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 09:43:09 AM
Ok if the minimum armor rule is not applied for hit strength (I did not know this) then ignore that part. More shortly. Sorry I'm typing in short bursts again

- here's what it looks like from the previous code:

# this is the default distribution of damage to armor cells
b <- matrix(0,nrow=5,ncol=5)
b[1:5,2:4] <- 1/30
b[2:4,1:5] <- 1/30
b[2:4,2:4] <- 1/15
b[1,1] <- 0
b[1,5] <- 0
b[5,1] <- 0
b[5,5] <- 0

#this function generates a sum of matrices multiplied by the distribution

createhitmatrix <- function(acc){
  hitmatrix <- matrix(0,5,ship[6]+4)
  distributionvector <- createdistribution(acc)
  for (i in 1:ship[6]){
    hitmatrix[,i:(i+4)] <- hitmatrix[,i:(i+4)]+b*(distributionvector[i+1])
  }
  return(hitmatrix)
}

The code I posted above contains a fully working example. I'll re post it next message.

Here is an example of the D matrix from a ship with 2 armor cells with SD = 50 and spread = 0


> weapon1[[8]]
[[1]]
           [,1]       [,2]       [,3]       [,4]       [,5]       [,6]
[1,] 0.00000000 0.01620322 0.03240644 0.03240644 0.01620322 0.00000000
[2,] 0.01620322 0.04860966 0.06481287 0.06481287 0.04860966 0.01620322
[3,] 0.01620322 0.04860966 0.06481287 0.06481287 0.04860966 0.01620322
[4,] 0.01620322 0.04860966 0.06481287 0.06481287 0.04860966 0.01620322
[5,] 0.00000000 0.01620322 0.03240644 0.03240644 0.01620322 0.00000000
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 24, 2022, 09:55:35 AM
Not gonna lie, your notation is very terse and I haven't felt like figuring it out in detail until now lamo.

Looking at it more closely, I find it really odd that D matrix, including probabilities, is going into the armor damage reduction. I don't think those probabilities should be appearing in the h/(h + a) expression. Probability should not be passed through the non-linear function. They just multiply the results.

Also, I don't really understand why h is being multiplied by the D array either? only armor should be multiplied by it.

The correct armor damage multiplier considering pooling should be something like hit_strength/(hit_strength + sum(wij*aij)) where wij are the associated weights for inner and outer cells.

Overall, I think the main difference in our approaches, is that you are trying to loop through the armor cells and calculate each one individually, and then handle the different possibilities of shot locations inside those expressions. What I am doing is looping through the shot locations with outer loop and then accounting for all the cells affected by that shot location in an inner loop (which is actually vectorized). My approach is very convenient because the armor pooling calculations and damage distribution are a function of the shot location, so it's very easy to code.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 09:56:33 AM
Attached to this message is a fully functioning copy in R and the RCpp code.

Replies
Quote
s I perhaps should have because you (as I do) often change your post after making it or later post another one correcting what you wrote.  I've been waiting for you to settle down on your best idea

Yeah sorry it's a side effect of writing in 5 minute bursts while doing other stuff, I told you about my schedule earlier and that wasn't even all

Quote
Also, why are we applying probability before the damage distribution?  Should we not do some sort of complicated average later instead? 

Exact same thing. You would end up with the matrix D from the latex. This is a convenient way of contributing it. In a sum and product operation the order doesn't matter.

Quote
Of what size, exactly?  Row padded on all four sides?
5 x shipcells + 4

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 24, 2022, 10:01:24 AM
Attached to this message is a fully functioning copy in R and the RCpp code.

Ok, so is this it?  If so, I will start translating to Python.  :)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 24, 2022, 10:11:08 AM
You definitely can't not do armor pooling. If you aren't doing it, something is wrong. The pooled armor goes through the non-linear armor damage multiplier, so you have to do it, I'm pretty sure.

I think your Xij expression is wrong. Or more precisely, not equivalent to what happens in the game.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 10:20:12 AM
Not gonna lie, your notation is very terse and I haven't felt like figuring it out in detail until now lamo.

What? Based on peer grading in my maths program I thought I was one of the most verbal  :D

As for the lack of pooling then basically the idea is just that you must either pool armor - which is non-trivial due to what was said above - or distribute damage and compare hit strength locally.

I'm.not going to argue that it is complicated and weird and possibly wrong in some way, it's just the best I could come up with, so just show your method works for arbitrary probability distributions (or ok, convolutions of uniform and normal distributions) and arbitrary weapon combos and you likely have yourself two converts. Since Liral is writing modular code this should be fixable later if there's a better idea, anyway.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 10:22:54 AM
Attached to this message is a fully functioning copy in R and the RCpp code.

Ok, so is this it?  If so, I will start translating to Python.  :)

That's the one to produce graphs. It would probably be smart to make a similar thing in Python to produce graphs, so we can try combos of weapons vs eg the old or Vanshilars or new experimental data to make sure results are sane, before proceeding to the optimization.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 24, 2022, 10:28:02 AM
Liral, did you see the python code I posted a little earlier? Pretty sure that is correct unless you can find some errors. It is based on code that matched other results previously.

CapnHector, it looks like your armor calculation for an armor cell only use the value of that same armor cell. That sort of defeats the purpose of the armor gird. The whole idea is that adjacent cells are contributing armor? I don't really see the point in using something that is 'weird and possibly wrong in some way' when there is code that (I think) is correct?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on November 24, 2022, 10:34:19 AM
@intrinsic_parity

Nice code! Very readable and clear which is always the most important thing in code when getting it working/checking it. In terms of optimizations I see a few things that should help but will reduce readability. In particular, avoiding instantiating new arrays when possible should give a large performance boost.

The first that pops out to me is that the getArmorIndexes is instantiating 2 new arrays each time it is run, but this is never used in parallel IE its never needed for 2 different sets of masks to exist at the same time. So, if an array for inner_cells and outer_cells is instantiated once externally, then passed as arguments into this function, that could be avoided... of course now the code has to keep python's referencing scheme really solidly in mind or its going to have odd bugs, and also needs to be careful about only using 'in place' operations which can be nasty if you aren't used to it because of python's opaque passing model.

For in place operations, probably the most helpful thing is that all of the +=, *=, etc operations are by default in place, so using them is 'safe'. For boolean operations using += and *= can change the data type to int by accident, but using the bitwise operators &= and |= are safe. |=True will flip elements to True, while &=False will flip elements to False. There is also numpy.copyto, which is in place and also accepts an optional mask! Seems perfect for later work if the base operations are too clunky.

It's easy enough in python to do accidental initializations that I'd want to do comparative unit tests between and final code and a equivalent but deliberately re-initializing code to make sure there is a time difference. Kind of blarf, optimization sucks!

As you pointed out armor_grid.shape doesn't change, so can be precomputed and passed as an argument, though its probably not costing very much. The bounds checking can be commented out for a "production" run or put on an if debug==True statement.

'damage_armor' is similarly a new array, but only one is ever used in the calculations at a time, so can be externalized and only modified in place instead of instantiated (again with *=, += etc functions).

'damage_hull' in armor_update doesn't need to be instantiated at all as an array as every operation on it is elementwise and its not returned. I think
Code
damage_hull_total = np.sum(np.maximum(damage_armor - armor_grid, 0) * dmg[2] / dmg[1])
Will do the truck because numpy should detect that every function is elementwise... but I'd want to test to be sure and I could be wrong there.

There are probably other small things, but this is the thing that pops out to me!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 10:38:01 AM
Liral, did you see the python code I posted a little earlier? Pretty sure that is correct unless you can find some errors. It is based on code that matched other results previously.

CapnHector, it looks like your armor calculation for an armor cell only use the value of that same armor cell. That sort of defeats the purpose of the armor gird. The whole idea is that adjacent cells are contributing armor? I don't really see the point in using something that is 'weird and possibly wrong in some way' when there is code that (I think) is correct?

I mean I'm not going to argue if Liral decides that way of calculating armor is better. If you tested it vs a uniform distribution and single shots then that doesn't confirm it at all, but if it produces the correct results for weapon combinations using the correct (convolved normal) distributions it is obviously the better code and formulation.

The best way to find out is just write the utility to use that code to handle actual weapons and see if the results are correct.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 24, 2022, 10:54:58 AM
@Thaago
Yeah, I knew there were a bunch of unnecessary temporary variables (mentioned it in an edit lol). I guess I could probably use comments to give the same level of readability, but it just feels nice.

In terms of pre-initializing the arrays outside of the functions, they would have to get passed down a significant function call stack because initializing them in the armor damage function doesn't do anything, and that get's called expected armor function too, so possibly 3-4 functions. Also, kinda difficult to do without having the rest of the higher level code yet. Another option is to try and generate a list of indices instead of a full boolean array for the inner/outer cell arrays which at least would be a much smaller temporary variable. I might try that later.

Also, all those weird idiosyncrasies are why I like to just use Julia these days lmao.

@CapHector
The distribution shouldn't matter that much because expected value doesn't care! E[f(x)] = sum(f(X)*p(X)) for X in x. f(X) doesn't care what the probability distribution is, it's all handled by p(X). All I've done is implement that E[f(x)] expression. The p(X) stuff can all be done independently. If you wanted to calculate variance, you would have a lot more trouble though.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on November 24, 2022, 11:01:27 AM
Oh yeah, if a full implementation isn't running and verified to be outputting correct data (and to which an optimized version can be compared with to check integrity) then any optimization is a total waste! I haven't used Julia myself but I have a friend who really likes it for numerical work.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 11:13:58 AM
E(f(x))=sum(f(X)p(X)) for a discrete random variable undoubtedly. The main thing is what is f(x). In terms of the total damage dealt to the armor matrix around x by the shot hitting x it should be exactly correct to say that for that damage it must be d*h/(h+sum of armor around cell x)). You get to the equations I was doing by trying to figure the value of the damage distribution cell by cell around x.

Now it seems like, though, that you could just calculate the damage matrix D on the fly instead of pre-computing it. Then what you would get is this: the damage to be distributed when the armor hits central cell x, it is d*h/(h+sum of armor around cell x)) damage to distribute around the cell x. You distribute it around the cell according to the default damage distribution matrix (1/30 peripheral and 1/15 central). Then you repeat this for all central cells before reducing armor so the hit strength calc is still correct*. Then the damage distributed to the peripheral cell is exactly the same as in the D_ij in the latex, so it is completely equivalent in this way.

Conversely, to go back from the latex matrix thing to this, you must pool d*D_ij*h/(h+armor value of cell)). Now if it should happen that the pooling process is just that you reverse the D_ij distribution and sum the armor value then the two formulations are actually exactly equivalent.

Given that the two formulations appear to be equivalent when thought about in this way, then it does seem that actually your formalism is better because it is simpler and computing the probability distribution on the fly is no longer any issue now that we have the analytical distribution (remember, I started with the simulated one, so I wanted to compute it beforehand).

I'd still feel safer if you give it a whirl with a normal distribution, but having gone through this mental calculation I'm actually going to switch to supporting your version of the code. Just integrate it as the armor damage calculation into the Pythonized version of the R code I posted earlier, and if it produces the same results we're done here.

Edit: added * because it's an important point that you can't do it central cell by central cell if you subtract the armor before you are done looping over the central cells as otherwise previous subtraction affects next hit strength while in reality they don't as they are simultaneous (literally part of one hit). So make sure to store it somewhere and subtract from all cells only when done in one step. Then it's correct.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on November 24, 2022, 12:06:41 PM
Anyway, your code seems mostly right, however there is one extra thing which is that hit strength and damage must be two different things. This is because for beams we have hit strength = dps / 2 but damage = beam ticks(adjusted for chargeup/chargedown)*dps/beam tick. We get the adjusted beam ticks from the hit time series function for each second. And, I'm not an expert on armor, but I think Vanshilar said earlier that cells with 0 armor can contribute to minimum armor even if pooled armor is not yet at the minimum armor threshold so that affects the order of sum and max operations. Vanshilar, can you confirm if I have this right?

Sorry, I haven't had time to look through the code, but I gave the mathematical operations for armor calculation here (https://fractalsoftworks.com/forum/index.php?topic=25536.msg380680#msg380680).

If you assume that shots are only going to hit along a single row, then you only need a one-dimensional vector of size 1 x (N-4) representing the probability distribution of which cells the shots may hit. That's what I called the "hittable armor cells". The actual armor grid that you have to simulate will have dimensions 5 x N. (I define N as the width of the armor grid you have to simulate, but some have defined N as the width of the hittable cells, which is 4 less; either is fine as long as it's kept consistent.)

You only have to pool the armor values for the cells in that vector, since those are the only ones that can be hit. The pooling will be that for each hittable cell, add the armor contributions of the cells around it, then if this sum is less than 5% of the base armor rating, it'll get to count as 5% of the base armor rating when calculating the hitstr/(hitstr+armor) damage reduction. This 5% minimum armor doesn't apply anywhere else.

So in terms of code, I'd expect it to be something like, calculate the pooled armor values (reducing the 5 x N armor matrix into a 1 x (N-4) vector), then just say armor_for_dam_reduction_calc (vector) = max(pooled armor values (vector), 0.05*base armor rating (scalar)). From there, the damage reduction multiplier is just hit_strength/(hit_strength + armor_for_dam_reduction_calc), noting that hit_strength is a scalar and armor_for_dam_reduction_calc is a one-dimensional vector. This vector then needs to have a max(vector, 0.15) applied to it due to 15% minimum damage. (Although I would recommend having that 0.15 as a parameter instead since it could be 0.10 if the target has Polarized Armor.) Then multiply this vector by the weapon damage, then dot with the probability distribution (which is a vector of the same size), then you spread out the damage into a 5 X N matrix, then you apply it to the armor matrix. All that is done in the Excel spreadsheet I posted.

I *think* it should be possible to vectorize the calculations with the armor matrix being pooled into a single hittable cell vector, and then the damage from the vector being spread out into the armor matrix again, by using the kth dimension and creating a matrix of size 5 x N x (N-4), which is just the 5 x 5 armor distribution (with the 1/15's and the 1/30's) being offset by 1 at each kth index. (Mentally, I have the image of books being offset when stacked, like here (https://media.cheggcdn.com/study/662/662fbf31-7c1b-49c5-97cc-101df2330024/image.png).) Then you can multiply the armor grid (N-4) times in the kth direction, dot it with this box, then sum along the ith and jth dimensions, and the resulting 1 x (N-4) vector will be the pooled armor values for the hittable cells. The same process in reverse to spread out the damage after it's calculated, from the 1 x (N-4) vector to the 5 x N armor matrix. This removes the need to cycle through the calculations for each hittable armor cell, but I'm not sure if it's 1) easily understandable and 2) actually faster in practice. It's basically just a convolution and using the kth index to do it. (It might need to be divided by the scalar (N-4) or something to make it work out mathematically, I'm not sure right now since I don't have the time to work it out.)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 24, 2022, 04:40:13 PM
E(f(x))=sum(f(X)p(X)) for a discrete random variable undoubtedly. The main thing is what is f(x). In terms of the total damage dealt to the armor matrix around x by the shot hitting x it should be exactly correct to say that for that damage it must be d*h/(h+sum of armor around cell x)). You get to the equations I was doing by trying to figure the value of the damage distribution cell by cell around x.
f(x) is just the calculations to determine armor damage given the shot location x. Exactly what you would do if there was no probabilistic aspect. If you compute the deterministic damage done for every possible shot location, multiply the damage by the probability of each shot location, and sum, you should get the expected value.

Conversely, to go back from the latex matrix thing to this, you must pool d*D_ij*h/(h+armor value of cell)). Now if it should happen that the pooling process is just that you reverse the D_ij distribution and sum the armor value then the two formulations are actually exactly equivalent.
I think this is where you're going wrong. A single armor cell value should not appear in the denominator of that fraction by itself, it has to be the pooled value. There's no way for sum(d*h/(h+armor value of cell))) to be equivalent to d*h/(h+sum(armor value of cell))) because the expression is non-linear.

If you wanted to think about it in terms of that D matrix, you're gonna have a different armor damage reductions for each probability pk because the pooled armor value is different for each possible shot location. So I don't think you can decompose it that way (separate the probabilities from the armor calculations like that) and still be correct. To put it another way, each shot location will  have an associated p*(adjusted damage) and the damage to a specific cell would be a weighted sum of those p*dmg terms over all the relevant shot locations. The best you could do to separate the probabilities would be to take the dot product of the probability vector with the adjusted damage, but that's already what I'm doing more or less. I just calculate all the adjusted damages first and then take the dot product at the end (I actually do a loop but looping a sum of products is equivalent to a dot product).

I'd still feel safer if you give it a whirl with a normal distribution, but having gone through this mental calculation I'm actually going to switch to supporting your version of the code. Just integrate it as the armor damage calculation into the Pythonized version of the R code I posted earlier, and if it produces the same results we're done here.
I would expect the results to not exactly match what you did because I don't think the two approaches are equivalent. Should definitely test and verify as much as possible though, and I'm happy to use all the probability distribution code that exists. I tried to make it modular so it could slot into other stuff easily.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 24, 2022, 05:12:20 PM
Updated code to eliminate some temporary variables. Now I have a big gross mess of a line of code though lol. Oh well, I'll consider trying to improve the approach to getting the correct armor cell indexes later if necessary. For now, this works.
Code
def armorUpdate(dmg, location, armor_grid, minimum_armor):
    # dmg is a vector with elements:
    # [shield_damage, armor_damage, hull_damage, shield_hit_strength, armor_hit_strength, hull_hit_strength]
    # location is a tuple of the coordinates in the armor grid where the shot lands
    # armor_grid is an array with each element corresponding to an armor cell
    # minimum armor is the smallest armor value the pooled armor is allowed to take. Typically .05*max_armor

    inner_cells, outer_cells = getArmorIndexes(location, armor_grid)

    damage_armor = (1 / 15 * inner_cells + 1 / 30 * outer_cells) * \
        np.maximum(dmg[4] / (dmg[4] + np.maximum(np.sum(armor_grid[inner_cells]) + 1 / 2 * np.sum(armor_grid[outer_cells]), minimum_armor)), .15) * dmg[1]

    damage_hull = np.sum(np.maximum(damage_armor - armor_grid, 0) * dmg[2] / dmg[1])

    return damage_armor, damage_hull
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 24, 2022, 08:22:31 PM

I would expect the results to not exactly match what you did because I don't think the two approaches are equivalent. Should definitely test and verify as much as possible though, and I'm happy to use all the probability distribution code that exists. I tried to make it modular so it could slot into other stuff easily.

Yeah let's rephrase: if your code with real weapons prints out correct results compared to experimental data with a reasonable adjustment of the adjustable SD parameter f, then it is the superior way to calculate this.

The concerns I had about parameters from outside the shot's field of view affecting armor damage reduction specifically disappear when you deduct all the damage from the entire distribution from the armor matrix in one step, as then it's not the case that previously computed cells affect damage reduction for the next, so then it should be the correct thing.

If not then let's try Vanshilar's tensor approach next. Sounds cool. :D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 25, 2022, 12:21:42 AM
Alright don't let this distract from any programming effort as I think the important thing right now is just to get the utility working with intrinsic_parity's code for the armor damage and the proper distributions and weapons time series from the R code. But here is an attempt to quantify analytically the error in intrinsic_parity's method, and it should be pretty small overall.
(edit: I have to re-do this again because of a silly computational error, doing this while doing something else again! repost soon)

E2: okay hit some problems. I've got this far:

(https://i.ibb.co/ysQTsX4/image.png) (https://ibb.co/3hCXh7R)

but then having problems applying this http://elib.mi.sanu.ac.rs/files/journals/publ/105/n099p107.pdf . So this might have to wait until I'm better at this stuff. Were we to pretend armor strength at cell is a random variable again then wr'd end up with epsilon/d < variance / hit strength ^2 again so very small errors expected. In fact running this estimate numerically with armor hp in cell assumed to be a uniform random variable between the expected damage and maximum armor, with armor and hit strength going from 150 to 1750 and 10 to 1000, I'm getting upper bounds of error typically at some single digit % of damage per shot over the entire range and mean upper bound of error as less than 1% of damage per shot over the entire range (original lower estimates revised a little higher as I looked at larger pieces of random armor). The largest errors are at the lowest hit strength compared to armor as we might expect, because if h >> a then h/(h+a) is approximately h/h so then there is no problem with the expected value.

If you're interested in what the raw error term would look like if the armor state were completely random before the shot, with each cell randomly (uniform) between shot damage and the armor's starting value, and we were to apply this method (it is not, that is a gross exaggeration, in real terms we should not even characterize it as a random variable in the simulation) then here is some code and output and a plot for mean (h/(h+a))- h/(h+mean(a)) when a is computed from 15 uniform random cells 10 times for each combination of hit str and armor.

code
Code

df <- data.frame()
 
for(a in seq(150,1750,5)){
  for(h in seq(10,1000,5)){
    if(h^2/(h+a) < a){
    randomnumbers <- vector(mode="double",length = 10)
    #to keep it simple we are going to look at 15 armor cells that are given equal weight rather than the real 21
    for (i in 1:10){
    armorcells <- runif(15,h^2/(h+a)/15,a/15)
    randomnumbers[i] <- sum(armorcells)
    }
    errorterm1 <- mean(h/(h+randomnumbers))
    errorterm2 <- h/(h+mean(randomnumbers))
    df <- rbind(df, c(errorterm1 - errorterm2, a, h))
    }
  }
}

colnames(df) <- c("error","armor","hitstrength")
library(ggplot2)
ggplot(df,aes(x=armor,y=hitstrength,col=error*100))+
  geom_tile()+
  labs(y="Hit strength", x="Ship starting armor", col="Error (%)")+
  scale_color_viridis_c()

max(df$error,na.rm=TRUE)

mean(df$error,na.rm=TRUE)

Output:
> max(df$error,na.rm=TRUE)
[1] 0.01162053
>
> mean(df$error,na.rm=TRUE)
[1] 0.001375372
[close]

(https://i.ibb.co/fNN1FLV/image.png) (https://ibb.co/6ttnwLc)

So it looks like even in this extreme circumstance the error in approximation is very small (scale is epsilon/d ie. error as % of shot damage).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 25, 2022, 08:18:38 AM
I have reformatted and partly-documented the NumPy code from instrinsic_parity

import numpy as np


def inner_and_outer_cells(location: tuple, armor_grid):
    """
    Return coordinates of armor cells affected
    by a hit at this location on an armor grid.
   
     OOO
    OIIIO       I = inner cell
    OIIIO       O = outer cell
    OIIIO
     OOO
   
    location - (x, y) integer pair relative to
               (0, 0) at lower-left
    armor_grid - a 2D array of armor cell values
    """
   
    inner_cells = np.full(armor_grid.shape, False)
    outer_cells = np.full(armor_grid.shape, False)

    inner_cells[location[0] - 1: location[0] + 2,
                location[1] - 1: location[1] + 2] = True

    outer_cells[location[0] - 2, location[1] - 1: location[1] + 2] = True
    outer_cells[location[0] + 2, location[1] - 1: location[1] + 2] = True
    outer_cells[location[0] - 1: location[0] + 2, location[1] - 2] = True
    outer_cells[location[0] - 1: location[0] + 2, location[1] + 2] = True
   
    return inner_cells, outer_cells


def pooled_armor(armor_grid, inner_cells, outer_cells):
    """
    Return the armor value pooled from the inner and
    outer cells per the Starsector game logic.
   
    armor_grid - a 2D array of armor cell values
    inner_cells - the 3x3 grid encompassing the hit cell
    outer_cells - the 3-long fringes around the inner_cells
    """
    return np.sum(armor_grid[inner_cells]) + np.sum(armor_grid[outer_cells]) / 2


def armor_and_hull_damage(
    armor_damage: float,
    hull_damage: float,
    hit_strength: float,
    minimum_armor: float,
    armor_grid,
    inner_cells,
    outer_cells
):
    """
    Return the damage of this hit to the hull and armor

    armor_damage - damage per shot (or per second) times
                   the corresponding damage type armor
                   damage factor of this hit
    hull_damage - damage per shot (or per second) of
                  this hit
    hit_strength - if hit is a beam tick, one half
                   armor_damage, else armor_damage
    location - armor grid coordinates of hit
    minimum_armor - least armor value the pooled
                    armor is allowed to take.
                    Typically .05*max_armor
    armor_grid - array with each element
                 corresponding to an armor cell
    inner_cells - 3x3 cell coordinate grid encompassing
                  the hit
    outer_cells - 3-long fringe outside each edge of
                  inner_cells
    """
    armor_strength = max(pooled_armor(armor_grid, inner_cells, outer_cells),
                         minimum_armor)
    armor_damage_factor = max(hit_strength / (hit_strength + armor_strength),
                              .15)
    damage_armor = ((inner_cells / 15 + outer_cells / 30)
                    * armor_damage_factor
                    * armor_damage)
    damage_hull = (np.sum(np.maximum(damage_armor - armor_grid, 0))
                    * hull_damage
                    / armor_damage)
    return damage_armor, damage_hull
   

def main():
    """
    A mudskipper hit with a 100 damage energy shot.
    """
    #ship
    armor_rating = 150
    minimum_armor = armor_rating * 0.05
    armor_grid = np.full((5, 7), armor_rating / 15)
   
    #hit
    location = (2, 3)
    inner_cells, outer_cells = inner_and_outer_cells(location, armor_grid)
    armor_damage, hull_damage, hit_strength = 100, 100, 100
   
    #result
    print(armor_and_hull_damage(armor_damage, hull_damage, hit_strength,
                                minimum_armor, armor_grid, inner_cells,
                                outer_cells))
main()


And verified its output to be the same as that of the original code.  I hope you find it easier to read and debug; I have also moved the reusable inner and outer cell determination outside the hit calculation.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 25, 2022, 09:49:53 AM
There will always be a tradeoff between readability and performance I suppose. I guess for now we can leave things in the most readable state until we get to a point where performance improvements might be worthwhile.

Also, CapnHector. The weights for pooling should be 1 or 1/2 depending on inner and outer cells. The initial values of the armor cells are 1/15th of the total armor, so they start on the 1/15th scale. The adjusted shot damage gets 1/15 and 1/30 multipliers when it is distributed amongst the cells so that the damage is on the same 1/15th scale as the cell armor values.

Here are the equations explicitly written out:
(https://i.imgur.com/frhv4gy.png)

Edit: looking at this closely, I'm realizing I could implement it with a single 'indexes' array instead of two separate 'inner' and 'outer' index arrays. That would theoretically save some time because I avoid some element wise additions and also only allocate one array, however, that array would be numerical instead of boolean, and I would be doing the pooling operations (multiplications and summations) over the entire array element-wise instead of the current implementation where I use logical indexing to only do the math on the non-zero multipliers. I'm sort of curious which would be faster.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 25, 2022, 10:24:31 AM
It's good that we started doing latex. What you wrote is super crisp to read and makes it quite clear what exactly is going on.

Regardless of any pooling or distribution, the error should still be due to using hit strength based on expected value of armor, instead of expected value of hit strength, no? Ie. E((h)/(h+armor)) vs h/(h+E(armor)). All the other operations are linear - or almost always linear at least. The upside is this should be small like you found out yourself in the monte carlo you did earlier. So this is looking very good. Unless I'm missing another source of error?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 25, 2022, 10:51:50 AM
It's good that we started doing latex. What you wrote is super crisp to read and makes it quite clear what exactly is going on.

Regardless of any pooling or distribution, the error should still be due to using hit strength based on expected value of armor, instead of expected value of hit strength, no? Ie. E((h)/(h+armor)) vs h/(h+E(armor)). All the other operations are linear - or almost always linear at least. The upside is this should be small like you found out yourself in the monte carlo you did earlier. So this is looking very good. Unless I'm missing another source of error?
I think the max() operations are non-linear too.

Also, I realized I forgot summations in the hull equation. And while I was at it, I moved some stuff out of the ArmorDamage equation so that it is not a function of the armor cell, and all that i,j stuff is handled in the armor and hull equations.
(https://i.imgur.com/RHU96ku.png)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 25, 2022, 11:39:08 AM
It's good that we started doing latex. What you wrote is super crisp to read and makes it quite clear what exactly is going on.

Regardless of any pooling or distribution, the error should still be due to using hit strength based on expected value of armor, instead of expected value of hit strength, no? Ie. E((h)/(h+armor)) vs h/(h+E(armor)). All the other operations are linear - or almost always linear at least. The upside is this should be small like you found out yourself in the monte carlo you did earlier. So this is looking very good. Unless I'm missing another source of error?
I think the max() operations are non-linear too.

Absolutely, but they are composed of 2 linear functions with 1 point where you switch functions - almost always linear, so if you look at the equation at either side of those points they are linear. The argument is that since the non-linearity is contained to 1 point in the simulation for each max() function, then that should not contribute significantly to the error in the whole simulation. You could also support this argument by saying the error is expected to transition from small to 0 at this point so that's another reason why those points shouldn't be significant and you can get rid of the max's for the analysis of maximum error.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 26, 2022, 07:41:43 AM
Vanshilar's idea of computing it using matrices and dots got stuck in my head, so out of sheer love of the subject here is a basic matrix and dot product implementation of intrinsic_parity's method (that is, true to the game damage calculations) using just 2-dimensional matrices. I do not know enough about computing to know whether it might speed up or slow down anything, but do note the matrix D is the exact same I've been harping on about and can be computed just once at the beginning of the simulation for each weapon and ship using just the overlapping sums method rather than matrix multiplication. But the big dream is that expressing the equations more concisely might lead to us one day being able to calculate E(h/(h+armor)) directly, maybe using law of the unconscious statistician, therefore eliminating the main source of error in the method.

Edit2: found a serious issue. Upping fixed version when I have the time to fix.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 26, 2022, 10:47:50 AM
I know there's one way to exactly calculate the EV, which is to treat the armor as a function of all of the shots up until this point. The problem is that you end up with a nested loop for every shot up until that point which is completely intractable computationally.

edit: I will also say, that I think it does satisfy the markov property, so I think you could categorize it as some sort of 'discrete time continuous state markov process'. In that case you should only have to consider the current state/shot. That's probably the right direction to search if you really want to figure it out, but I have a feeling the non-linearity will make it a bit to niche/specific for you to find to many results.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 26, 2022, 09:55:06 PM
Alright got it fixed. During writing this, I noticed that it is true that there is literally no way to write the matrix D to include the correct hit strength computation such that D is also constant using elementary operations. Maybe using tensors, but not using matrices. So I formally renounce my old method as incorrect, even though it produced some plausible results; this is the only correct way to calculate it regardless of results produced, so if they are incorrect it must be a bug in the code rather than math error.

I do think this brings us closer to computing E(h/(h+armor)) because now there is a consistent expression for where the randomness should be. Ie. it is specifically if you can formulate the column vector X_t to account for probability so you can directly compute E(X_t) then you remove the error from the calculation. Note that this can also be written without a Hadamard product using diagonal matrices instead if that's computationally advantageous.

(https://i.ibb.co/sPWcktd/image.png) (https://ibb.co/b3bYq7M)
(https://i.ibb.co/2hCCKKy/image.png) (https://ibb.co/1QPP22L)

Edit: notation. Edit2: more fixes to notation. It turns out choosing X as the symbol for the pooling matrix was highly unfortunate as it is identical to capital chi, making page 2 a little less readable, but I'll fix that another time. Edit3: placed eta in its right place on page 2. Edit4: add this note:

(https://i.ibb.co/mzRNYHp/image.png) (https://ibb.co/F7hHfBp)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 27, 2022, 10:49:15 AM
Ok sorry about double post but here's a thought that might help resolve the remaining inaccuracy:

Using law of the unconscious statistician, discrete form, and given that we've now split up the damage reduction into a vector, isn't it the case that, using the notation above,

E(armor damage reduction at cell k at t+1)=p_(k-2)*h/(h+Â1)+p_(k-1)*h/(h+Â2)+p_k*h/(h+Â3)+p_(k+1)h/(h+Â4)+p_(k+2)h/(h+Â5)+(1-p_(k-2)-...-p_(k+2))h/(h+pool(A_t))

Where Â1=pooled armor at k after shot hits cell k-2 with 100% probability at timepoint t
Where Â2=pooled armor at k after shot hits cell k-1 with 100% probability at timepoint t
Where Â3=pooled armor at k after shot hits cell k with 100% probability at timepoint t
etc.

Ie. sum of probability of hit locations times armor damage reduction if that hit happens for all hits affecting the pooled value at that location. So, calculating it like this, it would involve only 1 lookback, since we can presume the armor calculation is accurate from step to step?  I think in practice you should keep track of not only expected armor but also expected armor damage reduction, and update it after each shot.

Later in the week can maybe do some math again and build test code, but sound reasonable?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 27, 2022, 05:56:51 PM
Later in the week can maybe do some math again and build test code, but sound reasonable?

I have deleted all the old code as you requested and need a copy of the latest working R code, less the combat math.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 27, 2022, 07:54:34 PM
Hm? There should have been no changes to the hit distribution code or shot time sequence code, or even the main time series loop. Those are all fine. When I said I renounce my old model I was referring specifically to the armor damage. Now above I formulated it using linear algebra, but it's just equivalent to intrinsic_parity's formulation unless I figure out this expected hit strength thing fully. So the previously sent graph plotting thing code (weaponsdata4.R) should still be up to date.

Sorry about my ambiguous communication here.

I am thinking I should probably write a latex specification of the exact mathematics of this program and what it should do, if I can get this last piece of the puzzle to come together, rather than attempt to create any polished code since there are folks here who are much better than I am at the latter. Also it keeps happening that whenever I do the pen and paper math first I tend to produce much simpler operations than if I write code first and then try to make sense of it later or do the math based on the code. Valuable life lesson, too.

But there will be no changes expected to the rest of the code, since the distributions are figured out exactly, the time sequence for weapons fire is computed literally exactly as the game does it currently, shield damage is trivial since expected value of (damage times modifier) is just probability of hit times damage times modifier, and hull damage is always only the amount going through armor, re-scaled to modifier vs hull. So only the armor damage code might be re-specced but even then the current is fine and it would only be a slight improvement that can be slotted in if the code is modular.

Edit: here is some more work on this. This likely won't be computationally cheap (it involves computing literally as many armor matrices at each step as the ship has hittable horizontal armor cells) but the hope is that using linear algebra might offset some of the costs.

(https://i.ibb.co/P1t8nt3/image.png) (https://ibb.co/HGXQsX1)

Incidentally this suggests another way of calculating the damage to the whole armor to me: if we are calculating A_t star 1 ... A_t star n already, then we could just calculate the whole armor at the next step as A_(t+1) = p_1 * A_t star 1 + ... + p_n A_t star n.

This is how that would look as a computer program:

(https://i.ibb.co/jk5xndM/image.png) (https://ibb.co/8gBR1J5)

This is literally taking a probability weighted average of all possible hits at each step.

I do not currently have any ideas how to approach the expected value of the armor damage reduction without reference to law of the unconscious statistician (that would imply computing a probability density function for the armor state which is pretty hard) so computing all the hypothetical armor states is the only way to go, so might as well do it like this if going that route. Need to think a bit about how the two things interact to see how this might all work out exactly.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 28, 2022, 05:04:46 AM
Hm? There should have been no changes to the hit distribution code or shot time sequence code, or even the main time series loop. Those are all fine. When I said I renounce my old model I was referring specifically to the armor damage. Now above I formulated it using linear algebra, but it's just equivalent to intrinsic_parity's formulation unless I figure out this expected hit strength thing fully. So the previously sent graph plotting thing code (weaponsdata4.R) should still be up to date.

Sorry about my ambiguous communication here.

I said have deleted all the code, including the permutation generator, optimizer, etc., and wrote a Python file that extracts data from the game files and exposes it as a dictionary.  Writing this reply, I have reconsidered my request for the original R code because I remember another poster said that the brute-force search it implemented was inefficient, and besides, that code refers to data from an array rather than from a dictionary.  I should rather work backward from the expected hit strength math, which you might change greatly soon, so I suppose I will wait.  :-\  :(
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 28, 2022, 05:23:13 AM
Sorry about that Liral. It's just the math here is quite interesting, I can't stop trying to figure it out. At the same time it's going to take a little time to see if this is going anywhere or not, like it feels that every new equation opens a new possibility but can't tell if it's going anywhere meaningful yet and if I rush it I'm going to write something flawed again. But can't the hit probability distribution etc. be translated to Python? This is all purely armor damage, the number of shots time sequence algorithm and hit probability distribution algorithm should be completely fine. They implement math that's basically set in stone.

Brute force search comment was referring to how weaponsoptimize4.R just goes through all the combinations rather than more intelligent search if I understand what intrinsic_parity was saying, but I think the first thing would be to get a complete implementation of damage for a single ship in Python first and then worry about the optimization part.

Edit: attached in the zip are weaponsdata4.R (single ship script) and weaponsoptimize4.R (test of a selection of weapons vs. a variety of ships) . Wrong armor damage code, otherwise fine.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 28, 2022, 02:21:11 PM
Sorry about that Liral. It's just the math here is quite interesting, I can't stop trying to figure it out. At the same time it's going to take a little time to see if this is going anywhere or not, like it feels that every new equation opens a new possibility but can't tell if it's going anywhere meaningful yet and if I rush it I'm going to write something flawed again.

I didn't ask you to apologize or hurry but rather told you that I don't think I can do anything besides a database of game file information until you settle the math because the rest of the code is optimization, which you have below said should also be ignored for now, and displaying results that we still lack.

Quote
But can't the hit probability distribution etc. be translated to Python? This is all purely armor damage, the number of shots time sequence algorithm and hit probability distribution algorithm should be completely fine. They implement math that's basically set in stone.

Could you point out the math (and R-code) you mean, exactly?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 28, 2022, 11:13:13 PM
Well, for example the hit distribution function

Code

# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds))) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)+1] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds))) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)+1] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upperbounds[1],0,standard_deviation)
      for (j in 2:(length(upperbounds))) vector[j] <- pnorm(upperbounds[j],0,standard_deviation) - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)+1] <- 1-pnorm(upperbounds[length(upperbounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

The function of this is to return the probability masses for ship cells, ie. for each cell, the definite integral from that cell's lower bound to upper bound, and for the edge cells from negative infinity to lowest bound of first cell, and for the last cell 1-probability of all other cells. The distribution is
1) the convolved normal (Bhattacharjee) distribution using the formulation given by Wilkins, when sd > 0 and spread > 0
2) the normal distribution N(0, sd^2) when sd > 0 and spread = 0
3) the uniform distribution [-spread,spread] when sd=0 and spread > 0
4) the trivial distribution that all shots hit 1 cell when sd=0 and spread=0

Since there is absolutely nothing unclear about the math then it's just a matter of executing it in elegant code. Mine is probably not that, but works.

This should probably be its own module since this is completely independent from the other calculations.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 29, 2022, 11:07:54 AM
Alright so I've built a "technology demonstrator". This is NOT ready for prime time, rather, I plan to write a mathematical specification and lead the actual coding to people who know how to do that.

This uses the linear algebra presented above to compute every possible counterfactual ("A-star") and then constructs the armor state as a probability weighted sum of those. It also includes a simulation function to generate comparison data the classical way, simulating damage shot by shot as shots hit random places on the armor and then computing damage the same way the game does it (ie. intrinsic_parity's method). This uses the real (convolved normal) probability distribution and the exact same distribution for simulation and the model.

Here is a graph of the result. Bright line is model, other lines are the plots of 100 dominators undergoing simulation. 500 damage energy weapon.
(https://i.ibb.co/y6Kfyw3/image.png) (https://ibb.co/Cb417kr)

Now next when I have time I should see just how accurate this is and add a possible statistical correction.

testbench.R
Code

#0. define ship, distribution matrix X, and probability matrix.
#dominator
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
#damage
d <- 500
h <- 500
#constants
omega <- 0.05
kappa <- 0.15
a_0 <- ship[4]
n <- ship[6]
#X
x <- matrix(c(0,1/30,1/30,1/30,0,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,0,1/30,1/30,1/30,0),5,5)
#draw a random cell from a custom cumulative distribution function
custrandom <- function(dist){
  return(which(dist > runif(1,0,1))[1])
}
range <- 1000
#basic calculations
#this computes a weighted average but can be defined using linear algebra due to what we're working with
poolarmor <- function(matrix, index){
  pooledarmor <- 0
  for(i in 1:5) pooledarmor <- pooledarmor + 15* (x[i,] %*% matrix[,index+i-3])
  return(pooledarmor[[1]])
}
#compute armor damage reduction factor
chi <- function(matrix, index) return(max(kappa,h/(h+max(a_0*omega,poolarmor(matrix,index)))))
#deal damage, the linear algebra way
#for some ungodly reason R insists on transposing the vector
#i refers to row of armor cell, j to column, r to counterfactual vector (star)
damage <- function(damagematrix,i,j,r) return((d * x[i,] %*% damagematrix[(j-2):(j+2),r])[[1]])

create_b_star <- function(armormatrix){
  B_star_vector <- vector(mode="double",length=(n+8))
  for (i in 1:n) {
    B_star_vector[4+i] <- chi(A,4+i)
  }
  B_star <- diag(B_star_vector)
  return(B_star)
}

B_star
A
star_matrix <- function(matrixA,matrixB,r) {
  A_star <- matrix(0,nrow=length(matrixA[,1]),ncol=length(matrixA[1,]))
  for (i in 5:(length(matrixA[1,])-5)){
    for (j in 1:(length(matrixA[,1]))){
      print(c(i,j))
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j
                                                  , r))
    }
  }

  return(A_star)
}

star_matrix_wonky_hull_dmg <- function(matrixA,matrixB,r) {

  A_star <- matrix(0,nrow=length(A[,1]),ncol=length(matrixA[1,]))
  hulldamage <- 0
  for (j in 5:(length(matrixB[1,])-2)){
    for (i in 1:(length(matrixA[,1]))){
      print(c("i:",i,"j:",j,"r:",r))
      hulldamage <- hulldamage + max(0, damage(matrixB, i, j, r) - matrixA[i,j])
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j, r))
    }
  }
  A_star[1,1] <- hulldamage
  return(A_star)
}
A
star_matrix_wonky_hull_dmg(A,B_star,10)
#sd
serror <- 50
#spread
spread <- 10

#how much is the visual arc of the ship in rad?
shipangle <- ship[5]/(2* pi *range)
#how much is the visual arc of a single cell of armor in rad?
cellangle <- shipangle/ship[6]

#distribution
anglerangevector <- 0
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle
#now convert it to pixels

anglerangevector <- anglerangevector*2*pi*range

# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds))) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)+1] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds))) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)+1] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upperbounds[1],0,standard_deviation)
      for (j in 2:(length(upperbounds))) vector[j] <- pnorm(upperbounds[j],0,standard_deviation) - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)+1] <- 1-pnorm(upperbounds[length(upperbounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))

p <- hit_distribution(anglerangevector,serror,spread)
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
A
B_star
p
p_cum <- vector(mode = "double", length=length(p))
p_cum[1] <- p[1]
for (i in 2:length(p)) p_cum[i] <- sum(p[1:i])
generatetestdata <-0
if(generatetestdata == 1){
#generate simulated data using 100 models
testresults <- data.frame(hullhp=double(),shot=integer(),series=integer())
for (i in 1:100){
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0
while (hullhp > 0){
  shot <- shot+1
  A<-star_matrix_wonky_hull_dmg(A,B_star,custrandom(p_cum))
  hullhp <- hullhp - A[1,1]
  B_star <- create_b_star(A)
  testresults <-rbind(testresults, c(hullhp, shot, i))
}
}
colnames(testresults) <- c("hullhp","shot","series")
}
library(ggplot2)
ggplot(testresults,aes(x=shot,y=hullhp,group=series,col=series))+
  geom_line()

#now the simple model
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0
p
matrixlist <- list()

modelresults <- data.frame(hullhp=double(),shot=integer(),series=integer())

while (hullhp > 0){
  shot <- shot+1
  #2 rows of zeroes and 2 rows of padding
  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+4)
  hulldamage <- 0
  #probability list has list item 1 as missing
  for(r in 1:n) hulldamage <- hulldamage + matrixlist[[r]][1,1]*p[r+1]
  hullhp <- hullhp - hulldamage
  A <- A*(p[1]+p[length(p)])
  for(r in 1:n) A <- A + matrixlist[[r]]*p[r+1]
  for(i in 1:length(A[1,]))for(j in 1:length(A[,1])) A[j,i] <- max(A[j,i],0)
  B_star <- create_b_star(A)
  modelresults <-rbind(modelresults, c(hullhp, shot, i))
}
matrixlist

star_matrix(A,B_star,10)
colnames(modelresults) <- c("hullhp","shot","series")
modelresults$series <- 999
testresults <- rbind(testresults, modelresults)
matrixlist[[1]]
print(matrixlist[[r]])
ggplot(testresults,aes(x=shot,y=hullhp,group=series,col=series))+
  geom_line()
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 29, 2022, 02:05:49 PM
I tried to go through your math but it's pretty hard to follow (lots of superscripts and subscripts that aren't explicitly defined).

But I believe 'taking a probability weighted average of all possible hits at each step' is what I was already doing. That's what I was trying to communicate when I was citing "E[f(X)] = sum(p(x) * f(x)) for x in X" means. A probability weighted sum of the armor values over all possible hit locations. So I think my approach should already be equivalent to this, assuming all your math is correct, since (I think) I was trying to do the same thing. Please correct me if there is some distinction I'm missing.

However, I disagree with the statement 'Calculating E[A_t] is simple given E[A_(t-1)]'. In fact, this is the actual thing I don't know how to do.

Calculating E[A_t] given A_(t-1) (a specific value, not expected value) is simple and is what I believe we are doing here. But E[A_t] given E[A_(t-1)] is the hard part (I suspect it's not possible, and you need a full distribution of A_(t-1), not just Ev). Because E[A_(t-1)] is a function of A_(t-2) and so on, so you end up in the exact same situation as you started (not having the Ev of the previous iteration).

Unless you go all the way back to the initial shot. But then what you are doing is 'a weighted probability average of every possible SEQUENCE of shots up until this point, which is more or less computationally intractable since it scales with the number of combinations of possible shot locations up to that point.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 29, 2022, 07:59:08 PM
No, you are correct, this should be the same thing. Computing the probability weighted sum of damaged armor is exactly the same thing whether you are subtracting damage or adding armor. The main reason to check how accurate this is is a) we are now using real distributions and b) in case there was an error in either of our code.

So if this produces accurate results for reasonable combinations of values then there ought to be no reason to rewrite the code. But if I can later in testing discover an error that would be significant - say, a HAC vs an Onslaught's armor maybe, then this model should let us compute expected value of armor damage reduction by the math specified above. And then we know we can compute the next expected armor state.

The plan is to keep a separate matrix C of expected damage reduction values that is not calculated from the armor but instead by the the expression above and then use that to perform the calculation again after computing all the possibilities and using that to update armor. See how it goes. Appreciate if point out any logical problems you see.

Mathematically speaking the trick is the E(h/(h+poolA_(k(t-1)))=E(chi_k(t-1)). Since we now have an explicit expression of expected armor damage reduction at step t from 1) the current expected armor state, which we can calculate*, and 2) the expected value of the previous armor damage reduction, which we cannot compute explicitly as that is intractable, but can compute using induction since we do know armor damage reduction at the first step, and now have an induction formula, that should stop the loop. This is pretty non-trivial though so in practice I think the proof will be whether it improves the prediction or not. But first see if its needed because computationally expensive stuff.

* but again not explicitly, because we don't know expected armor damage reduction - but if we apply the induction formula that leads to an induction formula for armor also, since we do know the starting armor state, hence a "taking turns" approach of first compute hypotheticals, then compute expected armor damage reduction, then compute actual armor damage to get expected armor state. For clarity that is not present here but can be done from this as needed since we now have the fundamentals from the math.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 29, 2022, 08:34:16 PM
The function of this is to return the probability masses for ship cells, ie. for each cell, the definite integral from that cell's lower bound to upper bound, and for the edge cells from negative infinity to lowest bound of first cell, and for the last cell 1-probability of all other cells. The distribution is
1) the convolved normal (Bhattacharjee) distribution using the formulation given by Wilkins, when sd > 0 and spread > 0
2) the normal distribution N(0, sd^2) when sd > 0 and spread = 0
3) the uniform distribution [-spread,spread] when sd=0 and spread > 0
4) the trivial distribution that all shots hit 1 cell when sd=0 and spread=0

Since there is absolutely nothing unclear about the math then it's just a matter of executing it in elegant code. Mine is probably not that, but works.

This should probably be its own module since this is completely independent from the other calculations.

Code
import statistics


def row_hit_probabilities(
        bounds: list,
        standard_deviation: float,
        spread: float) -> list:
    """
    Return the hit probability mass for each cell of a row.
   
    Hit probability mass equals the definite integral of hit
    probability distribution, which depends on the standard
    deviation (sd) of the position of the row and spread of the shots
    fired at it.
   
    sd = 0 and spread = 0: trivial, all shots hitting 1 cell
    sd = 0 and spread > 0: uniform [-spread,spread]
    sd > 0 and spread = 0: normal N(0, sd^2)
    sd > 0 and spread > 0: convolved normal (Bhattacharjee)
        using the formulation given by Wilkins
 
    The bounds of this definite integral across the first, middle, and
    last cells are
   
    - first: negative infinity to lower bound of first cell
    - middle: lower bound of each cell to upper bound of that cell
    - last: upper bound of last cell to positive infinity
   
    Conveniently, that integral for the last cell equals one minus the
    total probability mass of the preceding cells.
 
    bounds - armor row cell upper bounds
    standard_deviation - standard deviation of the row position
    spread - angular shot dispersion
    """
    if standard_deviation == 0 and spread == 0: #all shots hit 1 cell
        return [1 if bounds[i-1] < 0 <= bound else 0 for i, bound in
                enumerate(bounds)]
    elif standard_deviation == 0: #uniform distribution
        return ([min(1, max(0, (bounds[0] / spread + 1) / 2))]
                + [(min(1, max(0, (bounds[i] / spread + 1) / 2))
                    - min(1, max(0, (bounds[i-1] / spread + 1) / 2)))
                    for i in range(1, len(bounds))]
                + [1 - min(1, max(0, (bounds[-1] / spread + 1) / 2))])
    elif spread == 0: #normal distribution
        cdf = statistics.NormalDist(0, standard_deviation).cdf
        return ([cdf(bounds[0])]
                + [cdf(bounds[i]) - cdf(bounds[i-1]) for i in
                    range(1, len(bounds))]
                + [1 - cdf(bounds[-1])])
    #convolved normal (Bhattacharjee) distribution
    dispersion = standard_deviation, spread
    return ([hit_probability_within(bounds[0], *dispersion)]
            + [hit_probability_within(bounds[i], *dispersion)
                - hit_probability_within(bounds[i-1], *dispersion)
                for i in range(1, len(bounds))]
            + [1 - hit_probability_within(bounds[-1], *dispersion)])
Test
Code
bounds = [-3, -2, -1, 0, 1, 2, 3]
print(row_hit_probabilities(bounds, 0, 0))
print(row_hit_probabilities(bounds, 0, 1))
print(row_hit_probabilities(bounds, 1, 0))
Result
[0, 0, 0, 1, 0, 0, 0]
[0, 0, 0, 0.5, 0.5, 0, 0, 0]
[0.0013498980316301035, 0.021400233916549105, 0.13590512198327787, 0.3413447460685429, 0.3413447460685429, 0.13590512198327787, 0.021400233916549105, 0.0013498980316301035]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 29, 2022, 11:46:58 PM
Looking good Liral. Do you have the hit probability function translated yet? It is this

Code
G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))

Another thing that is quite ready to be translated is the shot time sequence function. It takes in the chargeup and chargedown times, burst delay, burst size, ammo capacity (-1 for unlimited), ammo regen rate, reload size, travel time and weapon type  to output a discrete series of hits (in case of a beam, tics adjusted for intensity scaling quadratically during chargeup and chargedown. This is very straightforward ie. you compute literally at which timepoints shots hit (it is chargeup, then during burst the next shot hits at that point +burstdelay, then after the burst the next shot is after chargedown and chargeup, while at the same time you keep track of reloading, and then add traveltime and aggregate how many shots hit during each second for all seconds). This should be another module of its own since it is completely independent of the combat calculation.

Spoiler
Code

#times in seconds, ammoregen is in ammo / second
hits <- function(chargeup, chargedown, burstsize, burstdelay, ammo=UNLIMITED, ammoregen=0, reloadsize=0, traveltime=0, mode=GUN){
  #specify sane minimum delays, since the game enforces weapons can only fire once every 0.05 sec
  #for beams, refiring delay is given by burstdelay, for guns it is burstdelay in case burstdelay is > 0 (==0 is shotgun) and chargedown
  if(burstdelay > 0 | mode == BEAM) burstdelay <- max(burstdelay, global_minimum_time)
  if(mode == GUN) chargedown <- max(chargedown, global_minimum_time)
  #this vector will store all the hit time coordinates
  #current time
  #insert a very small fraction here to make time round correctly
  time <- 0.001
  #maximum ammo count is ammo given at start
  maxammo <- ammo
  #this is used to do ammo regeneration, 0 = not regenerating ammo, 1 = regenerating ammo
  regeneratingammo <- 0
  ammoregentimecoordinate <- 0
  ammoregenerated <- 0
 
  #we are firing a gun
  if (mode == GUN) {
    Hits <- vector(mode="double", length = 0)
    while(time < time_limit){
      time <- time + chargeup
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
     
      if (burstdelay == 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            ammo <- ammo - 1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
          }
        }
      }
      if (burstdelay > 0) {
        for (i in 1:burstsize) {
          if (ammo != 0){
            Hits <- c(Hits, time + traveltime)
            time <- time + burstdelay
            ammo <- ammo -1
            if (regeneratingammo == 0) {
              ammoregentimecoordinate <- time
              regeneratingammo <- 1
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
           
          }
        }
      }
      time <- time+chargedown
      if(time - ammoregentimecoordinate > 1/ammoregen){
        ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
        ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
        if(ammoregenerated >= reloadsize){
          ammo <- ammo+ ammoregenerated
          ammoregenerated <- 0
        }
        if(ammo >= maxammo){
          ammo <- maxammo
          regeneratingammo <- 0
        }
      }
    }
    timeseries <- vector(mode="integer", length = time_limit/time_interval)
    timeseries[1] <- length(Hits[Hits >= 0 & Hits <= 1*time_interval])
    for (i in 2:time_limit/time_interval) timeseries[i] <- length(Hits[Hits > (i-1)*time_interval & Hits <= i*time_interval])
    return(timeseries)
  }
  #we are firing a beam
  if (mode == BEAM) {
    chargeup_ticks <- chargeup/beam_tick
    chargedown_ticks <- chargedown/beam_tick
    burst_ticks <- burstsize/beam_tick
    #for a beam we will instead use a matrix to store timepoint and beam intensity at timepoint
    beam_matrix <- matrix(nrow=0,ncol=2)
    #burst size 0 <- the beam never stops firing
    if(burstsize == 0){
      for (i in 1:chargeup_ticks) {
        #beam intensity scales quadratically during chargeup, so
      }
      while ( time < time_limit) {
        beam_matrix <- rbind(beam_matrix,c(time, 1))
        time <- time+beam_tick
      }
    } else {
      while (time < time_limit) {
        if (ammo != 0){
          ammo <- ammo - 1
          if (chargeup_ticks > 0){
            for (i in 1:chargeup_ticks) {
              beam_matrix <- rbind(beam_matrix,c(time, (i*beam_tick)^2))
              time <- time+beam_tick
              if (regeneratingammo == 0) {
                ammoregentimecoordinate <- time
                regeneratingammo <- 1
              }
              if(time - ammoregentimecoordinate > 1/ammoregen){
                ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
                ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
                if(ammoregenerated >= reloadsize){
                  ammo <- ammo+ ammoregenerated
                  ammoregenerated <- 0
                }
                if(ammo >= maxammo){
                  ammo <- maxammo
                  regeneratingammo <- 0
                }
              }
            }
          }
          for (i in 1:burst_ticks){
            beam_matrix <- rbind(beam_matrix,c(time, 1))
            time <- time+beam_tick
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
         
          if (chargedown_ticks > 0){
            for (i in 1:chargedown_ticks){
              beam_matrix <- rbind(beam_matrix,c(time, ((chargedown_ticks-i)*beam_tick)^2))
              time <- time+beam_tick
            }
            if(time - ammoregentimecoordinate > 1/ammoregen){
              ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
              ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
              if(ammoregenerated >= reloadsize){
                ammo <- ammo+ ammoregenerated
                ammoregenerated <- 0
              }
              if(ammo >= maxammo){
                ammo <- maxammo
                regeneratingammo <- 0
              }
            }
          }
          time <- time + burstdelay
          if(time - ammoregentimecoordinate > 1/ammoregen){
            ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
            ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
            if(ammoregenerated >= reloadsize){
              ammo <- ammo+ ammoregenerated
              ammoregenerated <- 0
            }
            if(ammo >= maxammo){
              ammo <- maxammo
              regeneratingammo <- 0
            }
          }
        }
        time <- time + global_minimum_time
        if(time - ammoregentimecoordinate > 1/ammoregen){
          ammoregenerated <- ammoregenerated + floor((time - ammoregentimecoordinate)/(1/ammoregen))
          ammoregentimecoordinate <- ammoregentimecoordinate + 1/ammoregen*floor((time - ammoregentimecoordinate)/(1/ammoregen))
          if(ammoregenerated >= reloadsize){
            ammo <- ammo+ ammoregenerated
            ammoregenerated <- 0
          }
          if(ammo >= maxammo){
            ammo <- maxammo
            regeneratingammo <- 0
          }
        }
      }
    }
    timeseries <- vector(mode="double", length = time_limit/time_interval)
    for (i in 1:length(timeseries)) {
      timeseries[i] <- sum(beam_matrix[beam_matrix[,1] < i & beam_matrix[,1] > i-1,2])
    }
    return(timeseries)
  }
}
[close]

Apologies in advance for just copypasting the ammo reload function all over the place. It needs to be checked whenever we increment time, and should probably be its own function but it has so many parameters to pass I thought it was easier like this.





In weapon testing news I figured the next step is trying to look at the accuracy of the model with plots that make it easier on the eye.

To accomplish that, we do the following: First simulate the model, to get the time at which point the model kills the ship. Then simulate 100 enemy ships up to that point, letting hull become negative if it should to preserve statistical properties, then plot the median hull hp (because it is skewed) minus model (I've also plotted all the separate simulated ships). This lets us see just how much error there is in the model at worst.

Here is an example of a "hull residual" (ie. actual hull - predicted hull) plot with green being the median and yellow being the model of course. This is in units of % of starting hull. This is with a 300 damage energy weapon vs Dominator. It does not seem too bad, but I think this needs to be tested systematically next, when I next have time. I would say that if we end up with a 10% error somewhere then it's time to try the error correction. Let me know if you have any particular cases you want to see.

(https://i.ibb.co/z4T4SZt/image.png) (https://ibb.co/XCrCztG)


Code if anyone else wants to give it a whirl:

Spoiler
Code
#0. define ship, distribution matrix X, and probability matrix.
#dominator
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
#damage
d <- 200
h <- 200
#constants
omega <- 0.05
kappa <- 0.15
a_0 <- ship[4]
n <- ship[6]
#X
x <- matrix(c(0,1/30,1/30,1/30,0,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,0,1/30,1/30,1/30,0),5,5)
#draw a random cell from a custom cumulative distribution function
custrandom <- function(dist){
  return(which(dist > runif(1,0,1))[1])
}
range <- 1000
#basic calculations
#this computes a weighted average but can be defined using linear algebra due to what we're working with
poolarmor <- function(matrix, index){
  pooledarmor <- 0
  for(i in 1:5) pooledarmor <- pooledarmor + 15* (x[i,] %*% matrix[,index+i-3])
  return(pooledarmor[[1]])
}
#compute armor damage reduction factor
chi <- function(matrix, index) return(max(kappa,h/(h+max(a_0*omega,poolarmor(matrix,index)))))
#deal damage, the linear algebra way
#for some ungodly reason R insists on transposing the vector
#i refers to row of armor cell, j to column, r to counterfactual vector (star)
damage <- function(damagematrix,i,j,r) return((d * x[i,] %*% damagematrix[(j-2):(j+2),r])[[1]])

create_b_star <- function(armormatrix){
  B_star_vector <- vector(mode="double",length=(n+8))
  for (i in 1:n) {
    B_star_vector[4+i] <- chi(A,4+i)
  }
  B_star <- diag(B_star_vector)
  return(B_star)
}


star_matrix <- function(matrixA,matrixB,r) {
  A_star <- matrix(0,nrow=length(matrixA[,1]),ncol=length(matrixA[1,]))
  for (i in 5:(length(matrixA[1,])-5)){
    for (j in 1:(length(matrixA[,1]))){
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j
                                                  , r))
    }
  }

  return(A_star)
}

star_matrix_wonky_hull_dmg <- function(matrixA,matrixB,r) {

  A_star <- matrix(0,nrow=length(matrixA[,1]),ncol=length(matrixA[1,]))
  hulldamage <- 0
  for (j in 5:(length(matrixB[1,])-2)){
    for (i in 1:(length(matrixA[,1]))){
      hulldamage <- hulldamage + max(0, damage(matrixB, i, j, r) - matrixA[i,j])
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j, r))
    }
  }
  A_star[1,1] <- hulldamage
  return(A_star)
}


#sd
serror <- 50
#spread
spread <- 10

#how much is the visual arc of the ship in rad?
shipangle <- ship[5]/(2* pi *range)
#how much is the visual arc of a single cell of armor in rad?
cellangle <- shipangle/ship[6]

#distribution
anglerangevector <- 0
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle
#now convert it to pixels

anglerangevector <- anglerangevector*2*pi*range

# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds))) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)+1] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds))) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)+1] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upperbounds[1],0,standard_deviation)
      for (j in 2:(length(upperbounds))) vector[j] <- pnorm(upperbounds[j],0,standard_deviation) - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)+1] <- 1-pnorm(upperbounds[length(upperbounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))

p <- hit_distribution(anglerangevector,serror,spread)
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)

p_cum <- vector(mode = "double", length=length(p))
p_cum[1] <- p[1]
for (i in 2:length(p)) p_cum[i] <- sum(p[1:i])

#now the simple model
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0

matrixlist <- list()
modelresults <- data.frame(hullhp=double(),shot=integer(),series=integer())

while (hullhp > 0){
  shot <- shot+1
  #2 rows of zeroes and 2 rows of padding
  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+4)
  hulldamage <- 0
  #probability list has list item 1 as missing
  for(r in 1:n) hulldamage <- hulldamage + matrixlist[[r]][1,1]*p[r]
  hullhp <- hullhp - hulldamage
  A[1,1] <- 0
  A <- A*(p[1]+p[length(p)])
  for(r in 1:n) A <- A + matrixlist[[r]]*p[r+1]
  for(i in 1:length(A[1,]))for(j in 1:length(A[,1])) A[j,i] <- max(A[j,i],0)
  B_star <- create_b_star(A)
  modelresults <-rbind(modelresults, c(hullhp, shot, i))
}
shotlimit <- shot

colnames(modelresults) <- c("hullhp","shot","series")
modelresults$series <- 999

generatetestdata <-1
if(generatetestdata == 1){
#generate simulated data using 100 models
testresults <- data.frame(hullhp=double(),shot=integer(),series=integer())
hullhp <- ship[1]
for (i in 1:1000){
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0
while (shot <= shotlimit){
  shot <- shot+1
  A<-star_matrix_wonky_hull_dmg(A,B_star,custrandom(p_cum))
  hullhp <- hullhp - A[1,1]
  A[1,1]<-0
  B_star <- create_b_star(A)
  testresults <-rbind(testresults, c(hullhp, shot, i))
}
}
colnames(testresults) <- c("hullhp","shot","series")
}
library(ggplot2)



testresiduals <- testresults

testresiduals <- rbind(testresiduals, cbind(aggregate(hullhp ~ shot, testresiduals, FUN=median), series=500))
testresiduals <- rbind(testresiduals, modelresults)
testresiduals$hullhp <- testresiduals$hullhp/ship[1]
for (i in 1:shotlimit) {
  testresiduals[which(testresiduals$shot==i),1] <- testresiduals[which(testresiduals$shot==i),1] - modelresults[which(modelresults$shot==i),][[1]]/ship[[1]]
}
ggplot(testresiduals,aes(x=shot,y=hullhp*100,group=series,col=series,linewidth=floor(series/500)))+
  geom_line()+
  scale_colour_viridis_c()+
  scale_linewidth_continuous(range=c(0.1,1))+
  labs(x="Shot",y="Hull hp residual %")+
  theme(legend.position="none")

[close]

E2: alright hitting some problems here, this is damage 1000 energy weapon:

(https://i.ibb.co/SRpTPR0/image.png) (https://ibb.co/MMLTsMp)

Now I'd say the error is still tolerable but it's probably a good idea to try whether the suggested error correction actually works or not. I also really wonder about the source of this error. Shouldn't it be the case that E(x/(x+a)) is closer to E(x/x) = 1 when x is greater relative to a? It does not appear to be a random artifact since the magnitude of the error varies but it is always the same direction when running this over and over.

If all else fails there is also a really crude technique to compensate: collect data from a very large number of "hull residual plots" and plot some kind of an equation with shot spread, shot damage, hull hp and armor as predictors to get a simple correction to compensate for the error. The problem is I think this approach falls apart as soon as there are multiple weapons.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on November 30, 2022, 09:12:09 AM
To be honest. I don't really see how anything in your math corrects the error. At the end of the day, the source of the error is from using the expected value of the armor from the previous iteration to compute the next iteration (implicitly assuming E[ f( x ) ] = f( E[ x ] ) ), and it seems as though you are still doing that no? As long as you are plugging in an EV of armor as the prior armor value for the next iteration, you have not addressed the source of error.

Fundamentally, the armor at the previous iteration is a random variable, and we are substituting a deterministic variable (Expected value) in its place. Considering that there are infinitely many possible distributions with the same EV, it should be obvious why this is never going to be perfectly correct for a non-linear function like ours.

In terms of why larger shot values would cause more error, I think intuitively, it would make sense that large shot values would cause there to be a wider range of possible armor values after a single shot, meaning the distribution of armor values is 'larger' and perhaps more skewed, so then it would seem reasonable that the error due to not accounting for that distribution would be larger. But that's a very vague hand-wavy explanation.


Also, you are pretty loose with notation. It's really hard to tell what is a vector and what is an array, and what the product between them means (is that circle dot thing supposed to be some kind of element-wise multiplication of two vectors?, is x a cross product, or a scalar multiplication or something??). Also, I think you use capital X for two different matrices (the 1/15 array and also some array of chi values).

When I've written out lots of linear algebra type stuff, the convention has always been to use lower case symbols for scalars, bold lower case symbols for vectors (sometimes with a little arrow over them too), and capital non-bold symbols for matrices. I would highly recommend you adopt that convention for the sake of clarity.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 30, 2022, 02:33:47 PM
Well, the code seems to do something. While it seems simple it's actually incredibly complex with all the indexing and it's making my head ache. But here's something.
vs Dominator, first image: shot strength 1000, second image: shot strength 500, third image: shot strength 300, fourth image: shot strength 100
Green line: old model
Dark green line: median
Yellow line: new model



Spoiler
Code
#0. define ship, distribution matrix X, and probability matrix.
#dominator
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
#damage
d <- 100
h <- 100
#constants
omega <- 0.05
kappa <- 0.15
a_0 <- ship[4]
n <- ship[6]
#X
x <- matrix(c(0,1/30,1/30,1/30,0,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,0,1/30,1/30,1/30,0),5,5)
#draw a random cell from a custom cumulative distribution function
custrandom <- function(dist){
  return(which(dist > runif(1,0,1))[1])
}
range <- 1000
#basic calculations
#this computes a weighted average but can be defined using linear algebra due to what we're working with
poolarmor <- function(matrix, index){
  pooledarmor <- 0
  for(i in 1:5) pooledarmor <- pooledarmor + 15* (x[i,] %*% matrix[,index+i-3])
  return(pooledarmor[[1]])
}
#compute armor damage reduction factor
chi <- function(matrix, index) return(max(kappa,h/(h+max(a_0*omega,poolarmor(matrix,index)))))
#deal damage, the linear algebra way
#for some ungodly reason R insists on transposing the vector
#i refers to row of armor cell, j to column, r to counterfactual vector (star)
damage <- function(damagematrix,i,j,r) return((d * x[i,] %*% damagematrix[(j-2):(j+2),r])[[1]])

create_b_star <- function(armormatrix){
  B_star_vector <- vector(mode="double",length=(n+8))
  for (i in 1:n) {
    B_star_vector[4+i] <- chi(A,4+i)
  }
  B_star <- diag(B_star_vector)
  return(B_star)
}


star_matrix <- function(matrixA,matrixB,r) {
  A_star <- matrix(0,nrow=length(matrixA[,1]),ncol=length(matrixA[1,]))
  for (i in 5:(length(matrixA[1,])-5)){
    for (j in 1:(length(matrixA[,1]))){
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j
                                                  , r))
    }
  }

  return(A_star)
}

star_matrix_wonky_hull_dmg <- function(matrixA,matrixB,r) {

  A_star <- matrix(0,nrow=length(matrixA[,1]),ncol=length(matrixA[1,]))
  hulldamage <- 0
  for (j in 5:(length(matrixB[1,])-2)){
    for (i in 1:(length(matrixA[,1]))){
      hulldamage <- hulldamage + max(0, damage(matrixB, i, j, r) - matrixA[i,j])
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j, r))
    }
  }
  A_star[1,1] <- hulldamage
  #dumb hack
  return(A_star)
}


#sd
serror <- 50
#spread
spread <- 10

#how much is the visual arc of the ship in rad?
shipangle <- ship[5]/(2* pi *range)
#how much is the visual arc of a single cell of armor in rad?
cellangle <- shipangle/ship[6]

#distribution
anglerangevector <- 0
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle
#now convert it to pixels

anglerangevector <- anglerangevector*2*pi*range

# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds))) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)+1] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds))) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)+1] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upperbounds[1],0,standard_deviation)
      for (j in 2:(length(upperbounds))) vector[j] <- pnorm(upperbounds[j],0,standard_deviation) - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)+1] <- 1-pnorm(upperbounds[length(upperbounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))

p <- hit_distribution(anglerangevector,serror,spread)
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
p_cum <- vector(mode = "double", length=length(p))
p_cum[1] <- p[1]
for (i in 2:length(p)) p_cum[i] <- sum(p[1:i])

#now the complex model
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
C <- B_star
hullhp <- ship[1]
shot <- 0
matrixlist <- list()
modelresults <- data.frame(hullhp=double(),shot=integer(),series=integer())

while (hullhp > 0){
  shot <- shot+1
  B_star <- create_b_star(A)
#  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+2)
  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+4)
 
  #2 rows of zeroes and 2 rows of padding
  hulldamage <- 0
  #start with smaller bounds, expand to 5:n+4
  for(index in 1:n){
    rightside <- 0
    pooledprob <- 0
    #compute probability weighted average of counterfactuals
    for (grr in -4:4) {
      if((index+grr+4) > 3){ if(index+grr+4 < length(A[1,])-2){
        print(index)
        print(grr)
      rightside <- rightside + (chi(matrixlist[[index]],index+grr+4))*p[index+1+grr]
      pooledprob <- pooledprob + p[index+1]
      }
      }
    } 
    C[index+4,index+4] <- C[index+4,index+4]*(1-pooledprob) + rightside
  }
  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,C,r+4)
 
#  for(i in 1:length(C[,1])) for (j in 1:length(C[1,])) C[i,j] <- max(0,C[i,j])
  #probability list has list item 1 as missing
  for(r in 1:n) hulldamage <- hulldamage + matrixlist[[r]][1,1]*p[r+1]
  hullhp <- hullhp - hulldamage

  A <- A*(p[1]+p[length(p)])
  for(r in 1:n) A <- A + matrixlist[[r]]*p[r+1]
 
  A[1,1] <- 0
  for(i in 1:length(A[1,]))for(j in 1:length(A[,1])) A[j,i] <- max(A[j,i],0)
#  B_star <- create_b_star(A)
  modelresults <-rbind(modelresults, c(hullhp, shot, i))
}
shotlimit <- shot

colnames(modelresults) <- c("hullhp","shot","series")
modelresults$series <- 1000

#now the simple model
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0
matrixlist <- list()
simplemodelresults <- data.frame(hullhp=double(),shot=integer(),series=integer())
while (hullhp > 0){
  shot <- shot+1
  #2 rows of zeroes and 2 rows of padding
  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+4)
  hulldamage <- 0
  #probability list has list item 1 as missing
  for(r in 1:n) hulldamage <- hulldamage + matrixlist[[r]][1,1]*p[r+1]
  hullhp <- hullhp - hulldamage
  A[1,1] <- 0
  A <- A*(p[1]+p[length(p)])
  for(r in 1:n) A <- A + matrixlist[[r]]*p[r+1]
  for(i in 1:length(A[1,]))for(j in 1:length(A[,1])) A[j,i] <- max(A[j,i],0)
  B_star <- create_b_star(A)
  simplemodelresults <-rbind(simplemodelresults, c(hullhp, shot, i))
}

colnames(simplemodelresults) <- c("hullhp","shot","series")
simplemodelresults$series <- 750

generatetestdata <-1
if(generatetestdata == 1){
#generate simulated data using 100 models
testresults <- data.frame(hullhp=double(),shot=integer(),series=integer())
hullhp <- ship[1]
for (i in 1:100){
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0
while (shot <= shotlimit){
  shot <- shot+1
  A<-star_matrix_wonky_hull_dmg(A,B_star,custrandom(p_cum))
  hullhp <- hullhp - A[1,1]
  A[1,1]<-0
  B_star <- create_b_star(A)
  testresults <-rbind(testresults, c(hullhp, shot, i))
}
}
colnames(testresults) <- c("hullhp","shot","series")
}
library(ggplot2)



testresiduals <- testresults

testresiduals <- rbind(testresiduals, cbind(aggregate(hullhp ~ shot, testresiduals, FUN=median), series=500))
testresiduals <- rbind(testresiduals, modelresults)
testresiduals <- rbind(testresiduals, simplemodelresults)
testresiduals$hullhp <- testresiduals$hullhp/ship[1]
for (i in 1:shotlimit) {
  testresiduals[which(testresiduals$shot==i),1] <- testresiduals[which(testresiduals$shot==i),1] - modelresults[which(modelresults$shot==i),][[1]]/ship[[1]]
}
ggplot(testresiduals,aes(x=shot,y=hullhp*100,group=series,col=series,linewidth=floor(series/500)))+
  geom_line()+
  scale_colour_viridis_c()+
  scale_linewidth_continuous(range=c(0.1,2))+
  labs(x="Shot",y="Hull hp residual %")+
  theme(legend.position="none")

[close]

This thing is an absolute bummer to code and I can't guarantee that there are no bugs. Need to take time to go through this to see what the heck is going on.

Edit: retracted graphs since I found a bug in indexing. Damn this is hard. I'm thinking just build the code with intrinsic_parity's armor functions and this will be done some day for comparison, but not this week, I need a break from it. The previous residual plots apply equally to it since it ks literally the same thing expressed differently
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 30, 2022, 08:11:45 PM
To be honest. I don't really see how anything in your math corrects the error. At the end of the day, the source of the error is from using the expected value of the armor from the previous iteration to compute the next iteration (implicitly assuming E[ f( x ) ] = f( E[ x ] ) ), and it seems as though you are still doing that no? As long as you are plugging in an EV of armor as the prior armor value for the next iteration, you have not addressed the source of error.

Fundamentally, the armor at the previous iteration is a random variable, and we are substituting a deterministic variable (Expected value) in its place. Considering that there are infinitely many possible distributions with the same EV, it should be obvious why this is never going to be perfectly correct for a non-linear function like ours.

I hope our approach, albeit non-linear, is stable-enough for us afford to ignore this error. 

Quote
In terms of why larger shot values would cause more error, I think intuitively, it would make sense that large shot values would cause there to be a wider range of possible armor values after a single shot, meaning the distribution of armor values is 'larger' and perhaps more skewed, so then it would seem reasonable that the error due to not accounting for that distribution would be larger. But that's a very vague hand-wavy explanation.

The cause of this error is also why it increases with weapon damage and lies at the core of our model: CapnHector approximated weapon fire with a repeating shot sequence and has tried to derive a formula for the expected value of the effect of that sequence on shields and armor.  Weapons with low damage-per-shot must fire so many shots to destroy their target that we should expect the difference (i.e., error) between the actual and expected values of their effects to be tolerably small, but the inverse becomes true as damage-per-shot increases until exactly answering the questions of which weapon hits, which one misses, which order hits are in, and where they land becomes overwhelmingly more-important than the raw comparison of loadout statistics.

Quote
Also, you are pretty loose with notation. It's really hard to tell what is a vector and what is an array, and what the product between them means (is that circle dot thing supposed to be some kind of element-wise multiplication of two vectors?, is x a cross product, or a scalar multiplication or something??). Also, I think you use capital X for two different matrices (the 1/15 array and also some array of chi values).

When I've written out lots of linear algebra type stuff, the convention has always been to use lower case symbols for scalars, bold lower case symbols for vectors (sometimes with a little arrow over them too), and capital non-bold symbols for matrices. I would highly recommend you adopt that convention for the sake of clarity.

Hear, hear.  It also needs English to explain each new idea introduced.  Writing an intuitive English description of the rigorous mathematical statement one intends to write clarifies one's ideas and reveals opportunities or errors one might have otherwise neglected.   8)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 30, 2022, 08:53:27 PM
Looking good Liral. Do you have the hit probability function translated yet? It is this

Code
G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))

Code
import statistics

def hit_probability_within(
        bound: float,
        standard_deviation: float,
        interval_length: float) -> float:
    """
    Return the probability of a shot landing between 0 and a bound.
   
    bound - limit of where a shot might land and be included in
            this probability
    standard_deviation - standard deviation of the normal distribution
    interval_length - parameter of the uniform distribution
    """
    def f(x): #helper function for neatness
        normal_distribution = statistics.NormalDist()
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)
       
    return (standard_deviation / 2 / interval_length
            * (f((bound + interval_length) / standard_deviation)
                - f((bound - interval_length) / standard_deviation)))
Test
print(hit_probability_within(3, 1, 0.5))
Result
0.9980543437392928


Quote
Another thing that is quite ready to be translated is the shot time sequence function. It takes in the chargeup and chargedown times, burst delay, burst size, ammo capacity (-1 for unlimited), ammo regen rate, reload size, travel time and weapon type  to output a discrete series of hits (in case of a beam, tics adjusted for intensity scaling quadratically during chargeup and chargedown. This is very straightforward ie. you compute literally at which timepoints shots hit (it is chargeup, then during burst the next shot hits at that point +burstdelay, then after the burst the next shot is after chargedown and chargeup, while at the same time you keep track of reloading, and then add traveltime and aggregate how many shots hit during each second for all seconds). This should be another module of its own since it is completely independent of the combat calculation.

Sounds good!

Quote
Apologies in advance for just copypasting the ammo reload function all over the place. It needs to be checked whenever we increment time, and should probably be its own function but it has so many parameters to pass I thought it was easier like this.

I am too nervous about mistranslating one of these possibly slightly-different copy-pastes to translate this code.  If this function has too many parameters, some of which are stateful and must be regularly updated, then please consider creating an object that contains these parameters and has an update method.  For example,

Code
class DoctoralStudent:
    """
    The stereotypical doctoral student.

    Your results may vary.
    """

    def __init__(self, attributes: dict):
        self.money = attributes['what_money']
        self.student_loans = attributes['millstone']
        self.years_in_program = attributes['sentence']
        self.hunger_level = attributes['free_food_craving']
        self.qual_score = attributes['secret']
        self.career_prospects = attributes['engineering field']
        self.dissertation_status = attributes['TODO: add name for this']

    def update(self):
        """
        Another year...
        """
        self.hunger_level += 1
        if self.money > 0: #just for code coverage, not expected to run
            self.student_loans -= money
            self.money = 0
        if self.years_in_program >= 3:
            if self.dissertation_status == None:
                pass #TODO implement dissertation method
            elif self.years_in_program > 7:
                if self.career_prospects == None:
                    pass #do I need to even implement a worry method?
                else: #is this how it works?  someone said networking..., oh well, good approximation
                    self.money = self.dissertation_status * self.qual_score
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 30, 2022, 10:25:34 PM
Well here's the thing that I don't really know what an object is or understand how to write one and I'm dying of course and research work. The copypastes are all the same, though. Why not translate as is?

Anyway for sure I can explain what I'm trying to do with the error correction in plain English, although math is much preferred. A requisite preliminary is LOTUS (https://en.m.wikipedia.org/wiki/Law_of_the_unconscious_statistician). Without that this won't make any sense. About the math notation, I'll note that at the very start of my linear algebra 101 course we noted that vectors and matrices really represent mappings R^nxm->R such that x(i)->x_i if x is a vector and A(i,j)->A_ij when A is a matrix and no special symbols are used for them since they are really arbitrary sequences. And for some reason the official materials we have use this convention all the way through vector analysis. But I do note that another handout that we were given that uses geometry does use the bar symbol and the bolding does seem common and probably makes it more legible. I promise to write better notation if I ever get this working. On to the summary.

The problem is calculating the expected value of the armor at an arbitrary step, when we are given the state of the armor and armor reduction factors at step 1, and a probability distribution of hits. Now because of Jensen's inequality the mean of an inverse is not the same as the inverse of a mean. Therefore it is incorrect to compute the average armor at each step and use that to compute the expected armor damage reduction (although it is convenient and passably accurate and possibly what we will do anyway).

Now, assume for argument's sake, that we do know the expected armor state at time point t. Then look at column k in that armor state. What is the expected armor damage reduction at the next step for the central cell of column k? It is the probability weighted average of all possibilities for armor damage reduction at the central cell at the next step. But we do not know the probability distribution. However, we do know the probability distribution of hits and it is a discrete distribution. Therefore by LOTUS we can compute (armor damage reduction given hit)*probability of hit for all possible hit locations and associated probabilities of hits, describing the probability distribution in terms of the probability distribution of hits per LOTUS, discrete form. Note that we must also know the previous expected armor damage reduction, because we must be able to compute armor damage reduction given hit. So now we know how to calculate the expected value of armor damage reduction at the next step if we know the expected armor state at timepoint t and expected armor damage reduction at timepoint t. Therefore we can also compute the expected armor state at timepoint t+1, because it is sufficient to know the expected armor state at timepoint t and the expected armor damage reduction at timepoint t+1 and the probability of hits to calculate it (it is the same as the naive calculation but using the expected armor damage reduction derived from the previous expected armor damage reduction and the expected armor state instead of one derived from the expected armor state).

So now we know the expected armor state and damage reduction at t+1, if we know the expected armor state and damage reduction at t. This is usually intractable but we do explicitly know one such pair: the expected armor state and expected armor damage reduction at step 1. Therefore by induction we must be able to calculate the rest using the procedure above at each step. This concludes the method.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on November 30, 2022, 10:59:54 PM
Also, on a different note, here is a list of things that we need with check marks for whether we have it. I think we should have basically all that is needed to put together the first thing that should be built, which is a Python version of the graph generating thing to test that the output is correct (weaponsdata4.r).



R   Py
[ ] [?] Import data for ships and weapons (ships: hullhp, default flux dissipation, flux capacity, armor, width in px, no. armor cells, shield width in px, shield upkeep)
     (weapons: damage, chargeup, chargedown, burst size, burst delay, ammo capacity, ammo regen, ammo reload size, type (beam or not))
[v] [v] Create probability distribution of hits over ship
[v] [ ] Create sequence describing hits from weapon at timepoint (in whole seconds) during the simulation
[v] [v] Armor damage function to be used during combat simulation (the most complex part of the thing - use intrinsic_parity's)
[v] [ ] Main combat loop (
   flow: 1. check whether using shields to block -> do not block and dissipate soft and then hard flux if blocking would overload you,
   else block and dissipate soft flux only
   2. damage shields by damage to shields * probability to hit shields if blocking with shields
   3. damage armor using the armor damage function
   4. damage hull
   5. repeat until dead, record time to kill)
[v] [ ] Graph combat loop to make sure everything is working as needed
Next step when that is checked:
[v] [ ] Loop over weapons using a particular algorithm or just brute force, compare times to kill


Correct me if I have this wrong. But I think that should be the basic roadmap.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on November 30, 2022, 11:41:48 PM
Well here's the thing that I don't really know what an object is or understand how to write one

Say no more!   ;D  An object is data paired with methods, permitting code resembling written language rather than math.  R was not originally designed with objects, but the people who make R added them because they are useful.
someObject <- setRefClass(
    'className',
    fields = list(
        fieldName = 'typeName',
        otherFieldName = 'otherTypeName'
    ), methods = list(
        someFunction = function(arguments) {
        },
        otherFunction = function(otherArguments) {
        }
    )
)
Instead of passing (value, otherValue, anotherValue, yetAnotherValue) that all relate as loose arguments or a list, you can put all those arguments into an object to pass around and retrieve these values from its fields, which you name yourself, as you need them; e.g.,
someDoctoralStudent$money
returns the money of an instance of the DoctoralStudent class.  You can also mutate these fields; e.g.,
someDoctoralStudent$money = 1000
if you feel like making that instance rich. 

Quote
and I'm dying of course and research work. The copypastes are all the same, though. Why not translate as is?

If they're all exactly the same, then sure, it should be easy.

Quote
Anyway for sure I can explain what I'm trying to do with the error correction in plain English, although math is much preferred.

I meant English and math, side-by-side, as comments explaining pieces of complicated code would be.

Quote
A requisite preliminary is LOTUS (https://en.m.wikipedia.org/wiki/Law_of_the_unconscious_statistician). Without that this won't make any sense. About the math notation, I'll note that at the very start of my linear algebra 101 course we noted that vectors and matrices really represent mappings R^nxm->R such that x(i)->x_i if x is a vector and A(i,j)->A_ij when A is a matrix and no special symbols are used for them since they are really arbitrary sequences.

The problem that intrinsic_parity and I have with your notation is that it is inconsistent and undocumented: the above statement uses x to represent multiplication, a mapping function over the real numbers, and a vector element, all without acknowledging any of these uses, leaving me to squint and guess.  It would be better written as:

Vectors and matrices can be thought of as mappings from a real number space, with a number of dimensions equal to the n rows of a matrix or vector times the m columns of that matrix or vector, to a one-dimensional real number sequence.  A vector v could be thought of as such a mapping f that f(i) would return vi, which is the ith element of v.  A matrix A could be thought of as such a mapping g that g(i, j) would return Ai,j, which is the jth element of the ith row of A.

Quote
And for some reason the official materials we have use this convention all the way through vector analysis. But I do note that another handout that we were given that uses geometry does use the bar symbol and the bolding does seem common and probably makes it more legible. I promise to write better notation if I ever get this working. On to the summary.

Yes, vector symbols usually have arrows, and matrix symbols are usually bolded.  I'm very glad you will improve the notation!  :D

Quote
The problem is calculating the expected value of the armor at an arbitrary step, when we are given the state of the armor and armor reduction factors at step 1, and a probability distribution of hits. Now because of Jensen's (or geometric-arithmetic) inequality the mean of an inverse is not the same as the inverse of a mean. Therefore it is incorrect to compute the average armor at each step and use that to compute the expected armor damage reduction (although it is convenient and passably accurate and possibly what we will do anyway).

Now, assume for argument's sake, that we do know the expected armor state at time point t. Then look at column k in that armor state. What is the expected armor damage reduction at the next step for the central cell of column k? It is the probability weighted average of all possibilities for armor damage reduction at the central cell at the next step. But we do not know the probability distribution. However, we do know the probability distribution of hits and it is a discrete distribution. Therefore by LOTUS we can compute (armor damage reduction given hit)*probability of hit for all possible hit locations and associated probabilities of hits, describing the probability distribution in terms of the probability distribution of hits per LOTUS, discrete form. Note that we must also know the previous expected armor damage reduction, because we must be able to compute armor damage reduction given hit. So now we know how to calculate the expected value of armor damage reduction at the next step if we know the expected armor state at timepoint t and expected armor damage reduction at timepoint t. Therefore we can also compute the expected armor state at timepoint t+1, because it is sufficient to know the expected armor state at timepoint t and the expected armor damage reduction at timepoint t+1 and the probability of hits to calculate it (it is the same as the naive calculation but using the expected armor damage reduction derived from the previous expected armor damage reduction and the expected armor state instead of one derived from the expected armor state).

So now we know the expected armor state and damage reduction at t+1, if we know the expected armor state and damage reduction at t. This is usually intractable but we do explicitly know one such pair: the expected armor state and expected armor damage reduction at step 1. Therefore by induction we must be able to calculate the rest using the procedure above at each step. This concludes the method.

Ok, now I understand better!

Also, on a different note, here is a list of things that we need with check marks for whether we have it. I think we should have basically all that is needed to put together the first thing that should be built, which is a Python version of the graph generating thing to test that the output is correct (weaponsdata4.r).



R   Py
[ ] [?] Import data for ships and weapons (ships: hullhp, default flux dissipation, flux capacity, armor, width in px, no. armor cells, shield width in px, shield upkeep)
     (weapons: damage, chargeup, chargedown, burst size, burst delay, ammo capacity, ammo regen, ammo reload size, type (beam or not))
[v] [v] Create probability distribution of hits over ship
[v] [ ] Create sequence describing hits from weapon at timepoint (in whole seconds) during the simulation
[v] [v] Armor damage function to be used during combat simulation (the most complex part of the thing - use intrinsic_parity's)
[v] [ ] Main combat loop (
   flow: 1. check whether using shields to block -> do not block and dissipate soft and then hard flux if blocking would overload you,
   else block and dissipate soft flux only
   2. damage shields by damage to shields * probability to hit shields if blocking with shields
   3. damage armor using the armor damage function
   4. damage hull
   5. repeat until dead, record time to kill)
[v] [ ] Graph combat loop to make sure everything is working as needed
Next step when that is checked:
[v] [ ] Loop over weapons using a particular algorithm or just brute force, compare times to kill


Correct me if I have this wrong. But I think that should be the basic roadmap.


Looks good!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 01, 2022, 12:40:47 AM
Also, on a different note, here is a list of things that we need with check marks for whether we have it. I think we should have basically all that is needed to put together the first thing that should be built, which is a Python version of the graph generating thing to test that the output is correct (weaponsdata4.r).



R   Py
[ ] [?] Import data for ships and weapons (ships: hullhp, default flux dissipation, flux capacity, armor, width in px, no. armor cells, shield width in px, shield upkeep)
     (weapons: damage, chargeup, chargedown, burst size, burst delay, ammo capacity, ammo regen, ammo reload size, type (beam or not))
[v] [v] Create probability distribution of hits over ship
[v] [ ] Create sequence describing hits from weapon at timepoint (in whole seconds) during the simulation
[v] [v] Armor damage function to be used during combat simulation (the most complex part of the thing - use intrinsic_parity's)
[v] [ ] Main combat loop (
   flow: 1. check whether using shields to block -> do not block and dissipate soft and then hard flux if blocking would overload you,
   else block and dissipate soft flux only
   2. damage shields by damage to shields * probability to hit shields if blocking with shields
   3. damage armor using the armor damage function
   4. damage hull
   5. repeat until dead, record time to kill)
[v] [ ] Graph combat loop to make sure everything is working as needed
Next step when that is checked:
[v] [ ] Loop over weapons using a particular algorithm or just brute force, compare times to kill


Correct me if I have this wrong. But I think that should be the basic roadmap.


Looks good!

Great. Objects sound like a very useful thing. I should probably take an actual programming course instead of statistics with R courses sometime.

Anyway, there is one more thing I'd add to that roadmap. I think it would be a good idea to have a separate module that generates random hits according to the same probability distribution used in the model and simulates combat that way using the exact same functions that are used for armor damage. Then we'd have a way to plot whether the prediction is accurate to simulated results (which we know to be "correct" as much as the assumptions are, because we are doing things literally the same way as the game wrt damage). Like the hull residuals plots I did above. It's decidedly suboptimal to compare my hull residuals simulations to something written differently in Python as there might be an error in either one, and real results shouldn't be used to test whether the model is itself accurate either since the model includes an arbitrary parameter to be calibrated according to real results (the SD parameter) so basically even if the model is wrong you can calibrate it to look "right" if you haven't tested it vs simulations with the same calculation methods. Incidentally I think I fell into this trap originally because I didn't notice the error in using expected values originally, comparing it to real combat results only.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 01, 2022, 01:27:34 AM
Alright so in news related to the error correction model (I am way too obsessive by nature to leave something that is not working alone) it does improve the prediction a little. See below (green = old model, yellow = error corrected model, dark green = median)

Edit: arrrgh! I found an indexing error in the data to generate the control dominators! I guess this just goes to show that it's good to have several pairs of eyes on this and take it slow, so definitely make a comparison feature.

Now with that fixed here is how the models look (they are much more accurate)
500 damage shots vs Dominator:
(https://i.ibb.co/47GFCX6/image.png) (https://ibb.co/CzZw4Yk)
300 damage shots vs Dominator:
(https://i.ibb.co/BPDD9Rw/image.png) (https://ibb.co/b3hhkn7)
1000 damage shots vs Dominator:
(https://i.ibb.co/99jN9Wv/image.png) (https://ibb.co/C5Fz5Qt)
(by the way, this is related to the fact that for some reason that I do not really understand, the uncorrected model is more accurate for the initial shots and then the corrected model is more accurate in the long run, as illustrated by this: 1000 damage shots vs dominators, but with 140000 hull hp)
(https://i.ibb.co/0qtgKv0/image.png) (https://ibb.co/TcLV1dQ)
100 damage shots vs Glimmer:
(https://i.ibb.co/S5wKqKk/image.png) (https://ibb.co/2W3jXjz)
50 damage shots vs Glimmer:
(https://i.ibb.co/4VdH2n2/image.png) (https://ibb.co/gRVxdLd)

I am going to stick to the original post before I discovered the error's talking point, that this is not worth 27 x the cycles. Right now it computes 25 armor matrices and 2 matrices of damage reduction to do 1 step - and that's not all, computing the armor damage reduction expectations matrix takes armor 9 pooling operations per cell. And if I add another probability distribution on top to model non-linearity in min armor and min damage - which seems to still cause errors - then this is going to be such as > 100x cycles easy. At that point you might as well simulate 100 Dominators instead and avoid this non-trivial math and suspect programming.

I'm going to say go with the original (intrinsic_parity's code) and add a simulation mode so people with computing time to waste can actually run the 100 simulations per ship per weapon combination to get the accurate numbers.

code
Code
#0. define ship, distribution matrix X, and probability matrix.
#dominator
ship <- c(1500, 250/0.6, 2500/0.6, 200, 78, 5, 78*2, 0.6)
#damage
d <- 50
h <- 50
#constants
omega <- 0.05
kappa <- 0.15
a_0 <- ship[4]
n <- ship[6]
#X
x <- matrix(c(0,1/30,1/30,1/30,0,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,1/30,1/15,1/15,1/15,1/30,0,1/30,1/30,1/30,0),5,5)
#draw a random cell from a custom cumulative distribution function
custrandom <- function(dist){
  return(which(dist > runif(1,0,1))[1])
}
range <- 1000
#basic calculations
#this computes a weighted average but can be defined using linear algebra due to what we're working with
poolarmor <- function(matrix, index){
  pooledarmor <- 0
  for(i in 1:5) pooledarmor <- pooledarmor + 15* (x[i,] %*% matrix[,index+i-3])
  return(pooledarmor[[1]])
}
#compute armor damage reduction factor
chi <- function(matrix, index) return(max(kappa,h/(h+max(a_0*omega,poolarmor(matrix,index)))))
#deal damage, the linear algebra way
#for some ungodly reason R insists on transposing the vector
#i refers to row of armor cell, j to column, r to counterfactual vector (star)
damage <- function(damagematrix,i,j,r) return((d * x[i,] %*% damagematrix[(j-2):(j+2),r])[[1]])

create_b_star <- function(armormatrix){
  B_star_vector <- vector(mode="double",length=(n+8))
  for (i in 1:n) {
    B_star_vector[4+i] <- chi(A,4+i)
  }
  B_star <- diag(B_star_vector)
  return(B_star)
}


star_matrix <- function(matrixA,matrixB,r) {
  A_star <- matrix(0,nrow=length(matrixA[,1]),ncol=length(matrixA[1,]))
  for (i in 5:(length(matrixA[1,])-5)){
    for (j in 1:(length(matrixA[,1]))){
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j
                                                  , r))
    }
  }

  return(A_star)
}

star_matrix_wonky_hull_dmg <- function(matrixA,matrixB,r) {

  A_star <- matrix(0,nrow=length(matrixA[,1]),ncol=length(matrixA[1,]))
  hulldamage <- 0
  for (j in 5:(length(matrixB[1,])-2)){
    for (i in 1:(length(matrixA[,1]))){
      hulldamage <- hulldamage + max(0, damage(matrixB, i, j, r) - matrixA[i,j])
      A_star[i,j] <- max(0, matrixA[i,j] - damage(matrixB, i, j, r))
    }
  }
  A_star[1,1] <- hulldamage
  #dumb hack
  return(A_star)
}


#sd
serror <- 50
#spread
spread <- 10

#how much is the visual arc of the ship in rad?
shipangle <- ship[5]/(2* pi *range)
#how much is the visual arc of a single cell of armor in rad?
cellangle <- shipangle/ship[6]

#distribution
anglerangevector <- 0
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle
#now convert it to pixels

anglerangevector <- anglerangevector*2*pi*range

# this function generates the shot distribution (a bhattacharjee distribution for the
#non-trivial case)

hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds))) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)+1] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds))) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)+1] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upperbounds[1],0,standard_deviation)
      for (j in 2:(length(upperbounds))) vector[j] <- pnorm(upperbounds[j],0,standard_deviation) - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)+1] <- 1-pnorm(upperbounds[length(upperbounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
hit_probability_coord_lessthan_x <- function(z, a, b) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))

p <- hit_distribution(anglerangevector,serror,spread)
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
p_cum <- vector(mode = "double", length=length(p))
p_cum[1] <- p[1]
for (i in 2:length(p)) p_cum[i] <- sum(p[1:i])
augprob <- c(0,0,0,p,0,0,0)
#now the complex model
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
C <- B_star
hulldamage <- 0
hullhp <- ship[1]
shot <- 0
matrixlist <- list()
for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+4)

modelresults <- data.frame(hullhp=double(),shot=integer(),series=integer())

while (hullhp > 0){
  shot <- shot+1
  #compute hull damage according to C at previous step
  for(r in 1:n) hulldamage <- hulldamage + matrixlist[[r]][1,1]*p[r+1]
  hullhp <- hullhp - hulldamage
  #then, create the counterfactuals that a shot hits armor matrix A based on
  #unadjusted damage reduction calculation
  B_star <- create_b_star(A)
#  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+2)

  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+4)
  #2 rows of zeroes and 2 rows of padding
  hulldamage <- 0
  for(index in 1:n){
    rightside <- 0
    pooledprob <- 0
    #compute probability weighted average of counterfactuals
    for (grr in -4:4) {
      if((index+grr+4) >0){ if(index+grr+4 <= n){

      rightside <- rightside + (chi(matrixlist[[index+grr+4]],index+4))*augprob[index+4+grr]
      pooledprob <- pooledprob + augprob[index+4+grr]
      }
      }
    } 
    C[index+4,index+4] <- (C[index+4,index+4]*(1-pooledprob) + rightside)
    #possible corrections?
#    C[index+4,index+4] <- max(kappa,C[index+4,index+4])
#    C[index+4,index+4] <- min(C[index+4,index+4],h/(h+omega*a_0))
    print(C[index+4,index+4])
  }
 
  #now, compute the matrix C (next armor damage reduction)
  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,C,r+4)
 

  #then, update A accordning to C.
  A <- A*(p[1]+p[length(p)])
  for(r in 1:n) A <- A + matrixlist[[r]]*p[r+1]
 
  A[1,1] <- 0
  for(i in 1:length(A[1,]))for(j in 1:length(A[,1])) A[j,i] <- max(A[j,i],0)
#  B_star <- create_b_star(A)
  modelresults <-rbind(modelresults, c(hullhp, shot, i))
}
shotlimit <- shot

colnames(modelresults) <- c("hullhp","shot","series")
modelresults$series <- 1000

#now the simple model
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0
matrixlist <- list()
simplemodelresults <- data.frame(hullhp=double(),shot=integer(),series=integer())
while (hullhp > 0){
  shot <- shot+1
  #2 rows of zeroes and 2 rows of padding
  for(r in 1:n) matrixlist[[r]] <-star_matrix_wonky_hull_dmg(A,B_star,r+4)
  hulldamage <- 0
  #probability list has list item 1 as missing
  for(r in 1:n) hulldamage <- hulldamage + matrixlist[[r]][1,1]*p[r+1]
  hullhp <- hullhp - hulldamage
  A[1,1] <- 0
  A <- A*(p[1]+p[length(p)])
  for(r in 1:n) A <- A + matrixlist[[r]]*p[r+1]
  for(i in 1:length(A[1,]))for(j in 1:length(A[,1])) A[j,i] <- max(A[j,i],0)
  B_star <- create_b_star(A)
  simplemodelresults <-rbind(simplemodelresults, c(hullhp, shot, i))
}

colnames(simplemodelresults) <- c("hullhp","shot","series")
simplemodelresults$series <- 750

generatetestdata <-1
if(generatetestdata == 1){
#generate simulated data using 100 models
testresults <- data.frame(hullhp=double(),shot=integer(),series=integer())
hullhp <- ship[1]
for (i in 1:100){
A <- matrix(ship[4]/15,5,ship[6]+4)
#pad A
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(A,c(0,0,0,0,0))
A <- cbind(c(0,0,0,0,0),A)
A <- cbind(c(0,0,0,0,0),A)
B_star <- create_b_star(A)
hullhp <- ship[1]
shot <- 0

while (shot <= shotlimit){
  shot <- shot+1
  A<-star_matrix_wonky_hull_dmg(A,B_star,custrandom(p_cum)+3)
  hullhp <- hullhp - A[1,1]
  A[1,1]<-0
  B_star <- create_b_star(A)
  testresults <-rbind(testresults, c(hullhp, shot, i))
}
}
colnames(testresults) <- c("hullhp","shot","series")
}
library(ggplot2)



testresiduals <- testresults

testresiduals <- rbind(testresiduals, cbind(aggregate(hullhp ~ shot, testresiduals, FUN=median), series=500))
testresiduals <- rbind(testresiduals, modelresults)
testresiduals <- rbind(testresiduals, simplemodelresults)
testresiduals$hullhp <- testresiduals$hullhp/ship[1]
for (i in 1:shotlimit) {
  testresiduals[which(testresiduals$shot==i),1] <- testresiduals[which(testresiduals$shot==i),1] - modelresults[which(modelresults$shot==i),][[1]]/ship[[1]]
}

ggplot(testresiduals,aes(x=shot,y=hullhp*100,group=series,col=series,linewidth=floor(series/500)))+
  geom_line()+
  scale_colour_viridis_c()+
  scale_linewidth_continuous(range=c(0.1,2))+
  labs(x="Shot",y="Hull hp residual %")+
  theme(legend.position="none")

[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 01, 2022, 06:13:23 AM
I wonder if we could squeeze the last few percentage points of error out by adjusting the armor damage damage calculation with another fudge factor, which could depend on armor rating versus weapon damage and perhaps number of armor hits.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 01, 2022, 07:54:20 AM
Well, since it does look like the correction method works, then that implies the error is (other than the bulge at start, I do not know what that is) caused by expected armor damage reduction factors drifting away from armor damage reduction factors calculated based on expected armor (which of course causes expected hull to take a different path etc). So if there is a succinct correction to that then that will fix the core issue. It should actually be possible to plot the drift using the code I wrote although I am not at the computer at the moment so not now.

I am a little more skeptical on simply adding a term to the equation. It is subject to this criticism: if it is variable, how do we know it is fair to all weapons? If it is constant, what do we gain by adding it for the purpose of comparing weapons?

Edit to add: I also came up with a statistical theory about "the bulge" that has nothing to do with mechanics. Maybe it's just this: there is a period of time when the random ships can't deviate up from the model (they can't have more hull.than max) but can deviate down (when the model is not taking hull damage yet). To get the dark green curve we are taking a median of hullhp-model's hullhp. So the median can only go down at that point. Then further away when ships have been able to disperse both ways the median settles to the same expected value line as the corrected model. So the bulge is just an illusion created by the definition of the green line curve (well, not an illusion in the sense that the bulge does mean the model is not following the median there, and that is an actual concern in a time limited simulation, but an illusion in the sense that it does not need to imply new mechanics). Also the old model follows it because the old model is roughly an average of the ships to begin with but does not reach the true expected value due to above mentioned drift. It does not drift infinitely though as there comes a point when minimum armor kicks in and then the curves become parallel because the expected armor damage reduction calculation is eliminated as armor damage reduction is constant.

Anyway that is one way to make sense of the plots. If this were the cause, then there is quite a simple rough fix: compute both models in parallel, use the old until the corrected starts to take damage, then switch gradually to the new (it is not possible to go from model to model as giving the new the old's armor state would not work, so they must be run in parallel and averaged during the transition). But I'm not going to write it since we're probably not going with this model anyway.

I guess that leads to the next layer of this onion, which is this: why is one model accurate to long term expectations but not short term? That is, why is it initially more accurate to describe armor damage reduction as calculated from a mean of armors, and only later as the expected value? The answer is surely something to do with how the expected armor damage reduction is calculated since the models differ in armor damage reduction calculations only and use the same function for armor and hull damage.  Well, or it could also have to do with how hull damage is calculated.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 01, 2022, 11:57:45 PM
Getting back to "plotting the chi drift". Here is a plot of armor damage reduction multipliers (chi ie. the amount damage is multiplied by) for both models by cells, vs Dominator with 500 damage shots.

Corrected model:
(https://i.ibb.co/TTbfFgL/image.png) (https://ibb.co/j5WN1rR)

This tells me there is definitely still something screwy with the indexing, as cells 1 and 12 seem to degrade too fast and there are lesser problems with cells 2 and 11. Oh well. If only I could tell the computer to assume probability and armor contribution for any cells from outside the matrix is 0 instead of doing this crazy padding and limiting indices stuff then this would be so much easier. But maybe it doesn't matter, since we are not looking to actually use this model.

Here is the unadjusted model which has the same issue. At least that's good. All the models and simulations do use literally the same function to perform the armor damage calculations so an error there should affect them all the same way.
(https://i.ibb.co/CQM0C8D/image.png) (https://ibb.co/0rFJ42v)

Since we are not looking to improve the model but instead understand the drift, let's just drop the cells with indexing problems to get a graph like this (cells 4 to 10):
(https://i.ibb.co/QHvG9JY/image.png) (https://ibb.co/C9KFBwV)

Now looking at the difference we get this (there is another minor indexing issue visible somewhere, but again, maybe it's good enough because we're aiming to do some crude error correction with this stuff anyway)
(https://i.ibb.co/BBG6V5V/image.png) (https://ibb.co/ZxSg6p6)

Now it might be interesting to look at the probability weighted average of chi instead of raw chi because that is actually what determines total damage, so here's that.
Adjusted model:
(https://i.ibb.co/NyZ6vDv/image.png) (https://ibb.co/rw4Gj8j)

Naive model:
(https://i.ibb.co/NyZ6vDv/image.png) (https://ibb.co/rw4Gj8j)

Let's compute the difference, quotient (probability weighted sum over cells of: naive chi_cell)/(probability weighted sum over cells of:adjusted chi_cell) and square of the residual (probability weighted sum over cells of: (naive chi_cell - adjusted chi_cell)^2)
Difference:
(https://i.ibb.co/L6bb1Gz/image.png) (https://ibb.co/2Pmmdb7)
Quotient:
(https://i.ibb.co/PCTF9SB/image.png) (https://ibb.co/kc359dY)
Squares:
(https://i.ibb.co/VMSS2vg/image.png) (https://ibb.co/1Rqq89r)

The thinking here is that if we take a totally ham fisted approach and identify something that is approximately an appropriate statistical distribution (say, an F-distribution) somewhere then we could get an error correction without actually using the adjusted model. But I don't have one yet.

Rather than developing this model to correct for the errors and indexing requirements for the computer to do the matrix multiplication correctly, could see what the results look like if applying the error correction to the model that uses the loop and sum version of armor pooling. The error correction is simply that, for each central cell that can be hit, you first calculate all possible hypothetical armor states around that cell (to a distance of 4 cells) after a hit, then you take a probability weighted average of what the central cell's armor reduction would be given that hit with the probability weights being those of the hit distribution, and then you compute the actual damage to armor using the resulting probability weighted average for the armor damage reduction multiplier. Since the same pooled armor values and hypothetical armor scenarios get used over and over I suppose it could actually be possible to make the error corrected model significantly faster too, if you were to be clever about it, but there is no way around having to compute the damage to armor at least twice I think.

Just in case that is something that would interest you Liral, since you actually know this programming stuff, here is a description of an algorithm for error correction:


Step 0. For the very first step of dealing damage, you apply the naive armor damage reduction value based on the starting armor only.
  That is also the starting value for the expected armor damage reductions.
Loop:
Step 1. Deal damage to armor and hull based on the previously calculated (at step 3) expected armor damage reduction values for each central cell.
  Keep the expected armor damage reductions saved in a variable that is separate from the armor matrix as they are updated, not re-calculated.
Step 2. Compute, based on current armor state, the armor states that result from
  Substep 1. A hit to cell 1...
  Substep n. A hit to cell n, using armor damage reduction computed based on current armor. Save these armor states somewhere.
Step 3. Compute expected armor damage reductions for the next step, using the hypothetical armor states computed at step 2 as:
  Substep 1. At cell 1, the expected armor damage reduction is:
  The previous armor damage reduction, times probability that the shot does not land in cells 1 to 5, plus:
    Probability that the shot does land in cell 1, times armor damage reduction at cell 1 if shot lands in cell 1,
    plus probability that the shot lands in cell 2, times armor damage reduction at cell 1 if shot lands in cell 2,
    plus probability that the shot lands in cell 3, times armor damage reduction at cell 1 if the shot lands in cell 3,
    plus probability that the shot lands in cell 4, times armor damage reduction at cell 1 if the shot lands in cell 4.
    plus probability that the shot lands in cell 5, times armor damage reduction at cell 1 if the shot lands in cell 5.
  Substep k: For a cell in the middle of the armor, it is the analogous calculation as at substep 1
    but you look at the possibilities and armor damage reductions for if the shot were to land in cells
    k-4 to k+4 as those are the cells that would affect armor damage reduction at cell k.
  Substep n: Same, but the cells are n-4 to n.


It also occurs to me that since we are dealing with states that are necessarily symmetric you could actually cut the computation time in half by calculating these only up to the middle cell and then mirroring the rest.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 02, 2022, 09:18:41 AM
A couple quick thoughts:
Armor cells at the edge of the grid are less likely to be damaged (there are less possible hit locations that affect them) so I would expect them to not behave the same as center cells.

Assuming you are using a normal distribution as well, the curve for different cells should be different in general because the probabilities are different for each cell. So I don't think these results necessarily indicate errors in the code (although they could). I mostly just see the middle cells getting stripped quickly while the outer cells get stripped more slowly due to getting hit less often. That makes sense IMO.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 02, 2022, 08:53:09 PM

Step 0. For the very first step of dealing damage, you apply the naive armor damage reduction value based on the starting armor only.
  That is also the starting value for the expected armor damage reductions.
Loop:
Step 1. Deal damage to armor and hull based on the previously calculated (at step 3) expected armor damage reduction values for each central cell.
  Keep the expected armor damage reductions saved in a variable that is separate from the armor matrix as they are updated, not re-calculated.
Step 2. Compute, based on current armor state, the armor states that result from
  Substep 1. A hit to cell 1...
  Substep n. A hit to cell n, using armor damage reduction computed based on current armor. Save these armor states somewhere.
Step 3. Compute expected armor damage reductions for the next step, using the hypothetical armor states computed at step 2 as:
  Substep 1. At cell 1, the expected armor damage reduction is:
  The previous armor damage reduction, times probability that the shot does not land in cells 1 to 5, plus:
    Probability that the shot does land in cell 1, times armor damage reduction at cell 1 if shot lands in cell 1,
    plus probability that the shot lands in cell 2, times armor damage reduction at cell 1 if shot lands in cell 2,
    plus probability that the shot lands in cell 3, times armor damage reduction at cell 1 if the shot lands in cell 3,
    plus probability that the shot lands in cell 4, times armor damage reduction at cell 1 if the shot lands in cell 4.
    plus probability that the shot lands in cell 5, times armor damage reduction at cell 1 if the shot lands in cell 5.
  Substep k: For a cell in the middle of the armor, it is the analogous calculation as at substep 1
    but you look at the possibilities and armor damage reductions for if the shot were to land in cells
    k-4 to k+4 as those are the cells that would affect armor damage reduction at cell k.
  Substep n: Same, but the cells are n-4 to n.


It also occurs to me that since we are dealing with states that are necessarily symmetric you could actually cut the computation time in half by calculating these only up to the middle cell and then mirroring the rest.

Code
def hit_probability(cell): pass


def armor_damage_reduction(cell): pass


def hull_and_armor_damage(armor_damage_reduction): pass


def naive_armor_damage_reduction(starting_armor): pass


def expected_armor_damage_reduction(
        reduction: float,
        surrounding_cells: list) -> float:
    hit_probabilities = [hit_probability(cell) for cell in surrounding_cells]
    return (reduction
            * (1 - sum(hit_probabilities))
            + sum([hit_probabilities[i] * armor_damage_reduction(cell) for i,
                   cell in enumerate(surrounding_cells)]))


def error_correction_function(armor_row, hull):
    expected_armor_damage_reductions = naive_armor_damage_reduction(armor_row)
       
    def some_condition(): pass #what condition?
   
    while some_condition():
        #why are we skipping the first and last reductions?
        for i, reduction in enumerate(expected_armor_damage_reductions[1:-1]):
            hull_damage, armor_damage = hull_and_armor_damage(reduction)
            hull -= hull_damage
            armor_row[i+1] -= armor_damage
       
        possible_armor_states = [ #does the specification mention these again?
            armor_after_hit(cell, armor_damage_reduction(armor_row))
            for i, cell in enumerate(armor_row)
        ]
       
        expected_armor_damage_reductions = ( #what will we do with them?
            [expected_armor_damage_reduction(
                expected_armor_damage_reductions[0], armor_row[:5])]
            + [expected_armor_damage_reduction(
               expected_armor_damage_reductions[i+1], armor_row[i-1:i+4])
               for i, cell in enumerate(armor_row[1:-1])]
            + [expected_armor_damage_reduction(
               expected_armor_damage_reductions[-1], armor_row[-5:])]
        )
   
    #now what?
   

Here is my best guess of what you meant in Python with comments.  Please note the several methods I cannot recall.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 02, 2022, 09:50:26 PM
Great! But with some errors. Specifically you do not always set it to same as naive, only once. I am not sure what you mean by skip first and last reductions. Let me try to be more specific. Also, this is if you index ship cells from 1 to n. So if they are in a vector from 0 to n-1, then subtract 1 from each index. You do not need to compute expected ADR for the padding cells beyond hittable cells because "hitting" those directly must count as a miss and not do damage, even though they can be damaged indirectly by hitting the first and last armor cells.



Step 0. For the very first step of dealing damage, you apply the naive armor damage reduction value based on the starting armor only.
  Armor = starting armor
  That is also the starting value for the expected armor damage reductions.
   Expected adr= adr calculated from starting armor (a vector containing a separate value for each of the central cells)
Loop:
Step 1. Deal damage to armor and hull based on the previously calculated (at step 3) expected armor damage reduction values for each central cell.
  Keep the expected armor damage reductions saved in a variable that is separate from the armor matrix as they are updated, not re-calculated.

damage distributed over armor around middle cell due to hit at middle cell = damage*adjusted ADR for that middle cell
Damage over armor is calculated as usual, but using the expected adr rather than naive adr. However for step 0 they are the same, hence the special case above and the order in the loop.

Step 2. Compute, based on current armor state, the armor states that result from
  Substep 1. A hit to cell 1...
  Substep n. A hit to cell n, using armor damage reduction computed based on current armor. Save these armor states somewhere.


We use the hypothetical armor states here:
Step 3. Compute expected armor damage reductions for the next step, using the hypothetical armor states computed at step 2 as:
  Substep 1. At cell 1, the expected armor damage reduction is:
  The previous armor damage reduction, times probability that the shot does not land in cells 1 to 5, plus:
    Probability that the shot does land in cell 1, times armor damage reduction at cell 1 if shot lands in cell 1,
    plus probability that the shot lands in cell 2, times armor damage reduction at cell 1 if shot lands in cell 2,
    plus probability that the shot lands in cell 3, times armor damage reduction at cell 1 if the shot lands in cell 3,
    plus probability that the shot lands in cell 4, times armor damage reduction at cell 1 if the shot lands in cell 4.
    plus probability that the shot lands in cell 5, times armor damage reduction at cell 1 if the shot lands in cell 5.

   Compute:
naive ADR 1= hit strength/(hit strength+ pooled armor at cell 1 in hypothetical armor state where cell 1 was hit)
naive ADR 2= hit strength/(hit strength+ pooled armor at cell 1 in hypothetical armor state where cell 2 was hit)
...
naive ADR 5 = hit strength/(hit strength+ pooled armor at cell 1 in hypothetical armor state where cell 5 was hit)
expected ADR= expected ADR*(1-p1-p2-p3-p4-p5)+p1*naive ADR 1+p2*naive ADR2+...+p5*naive ADR 5
Where the p:s are hit probabilities for those cells (ie probability to hit cell 1, probability to hit cell 2, ...)

  Substep k: For a cell in the middle of the armor, it is the analogous calculation as at substep 1
    but you look at the possibilities and armor damage reductions for if the shot were to land in cells
    k-4 to k+4 as those are the cells that would affect armor damage reduction at cell k.
  Substep n: Same, but the cells are n-4 to n.

Alternative: same as above, but to save resources, only compute to middle cell m. Then for rest of the armor mirror expected ADR, so exp ADR n = exp ADR 1, exp ADR n-1 = exp ADR 2 etc.


At no point after step 0 are you allowed to compute expected ADR directly from the armor but instead it must be updated at each step as above
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 02, 2022, 10:05:54 PM
The only thing that doesn't make sense to me about your implementation is why you do the 1-p1-p2... thing. In the case where a shot doesn't hit any of the adjacent cells, the expected damage multiplier is just zero no?


Also, I had a though for how you might go about partially compensating for the use of expected armor as the input to the calculations each time. Basically just consider possible sequences of 2-3 shots prior.  The idea is sort of like a 'rolling' version of the full 'consider the probability of every possible sequence of shots'. So the idea would be you store with the expected armor from 2-3 shots ago, considering those 2-3 shots as independant random variables and consider all the possible combinations of shot locations from that sequence of shots and do a probability weighted average of all those outcomes. Honestly 3 shots might already be too burdensome computationally, but I'm curious how much of a difference it would make.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 02, 2022, 10:14:26 PM
You see, the goal is to express expected ADR according to probability distribution of hits. So if the shot misses, then the expected ADR for that probability is just the same as before, as nothing changes.

In terse notation we are computing

E(ADR) = sum ( ADR given hit * P(hit) ) over all hits that can affect ADR and for each central cell. The hit here refers to the next hit. Then in the next step we discard the hypotheticals and use what we calculated for E(ADR) to compute the expected damage from that hit and finally subtract that from our non-hypothetical armor state. (ADR given miss) is just the same ADR as before, since nothing changes for the armor when a shot misses.

But to avoid infinite loops we refer to the previous expected armor state to compute ADR given hit. We know the previous expected armor state is the true expected armor state if we have computed expected ADR correctly at every point, which is why we can do so (and also why we are not allowed to mix models or ever compute expected ADR directly from the armor after step 0).

There is one "easy" extra optimization. When the pooled armor gets below minimum armor threshold, you can drop this model and then just use the minimum ADR for that cell, since then there is no non-linearity anymore. This is the horizontal part in plots above.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 02, 2022, 10:50:02 PM
If the shot misses, it does no damage, so the damage reduction is zero? Unless you have some weird definition of ADR that you haven't defined anywhere?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 02, 2022, 11:14:12 PM
Okay so when I say ADR I mean chi but I'm trying not to use my cryptic notation. That is, hit strength/ (hit strength+ pooled armor). But I'm just going to say "chi" (from "function based on h") then. Chi means that. So given the armor does not change in a miss, chi at cell k given miss = chi at cell k. Remember we are looking at the probability of chi from the point of view of the armor cell, not from the shot's point of view. Here is a concrete example of the correct calculation. Say we have a gun and a laser alternating fire and the gun fires first.

Step 0 (preliminaries). Compute  expected chi from hit strength/(hit strength+ pooled armor) for each central cell.
Step 1. Gun shot 1. Compute expected chi after step 1 (expected chi for step 2) using (probability of misses)*(chi at step 0)+ sum(probability of gun hit*chi at cell given gun hit) as described above. Deal damage to armor using expected chi at step 0.
Step 2. Laser shot 1. Compute expected chi after step 2 (expected chi for step 3) using (probability of misses)*(chi at step 1)+ sum(probability of laser hit*chi at cell given laser hit) as described above. Deal damage to armor using expected chi from step 1.
Step 3. Gun shot 2. Compute expected chi after step 3 (expected chi for step 4) using (probability of misses)*(chi at step 2)+ sum(probability of gun hit*chi at cell given gun hit) as described above. Deal damage to armor using expected chi from step 2.

I think I got the timepoints wrong in my algorithm description but I hope this clarifies what should happen. In the algorithm it should be that you compute hypotheticals before applying damage to get the correct order, I think. I'm sure you guys are better at formulating it for the computer to understand though.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 02, 2022, 11:50:10 PM
Oh, are you doing something like saving the chi value from two shots ago and trying to avoid recomputing the value if the armor values didn't change because the last shot didn't hit in the area?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 03, 2022, 12:31:32 AM
Yeah that's the mathematical trick I was talking about earlier. The oversimplified summary is that in the uncorrected model we look at expected chi and damage both using the shot's probability distribution ("shot's point of view"). In the corrected we look at the chi from the armor's probability distribution and damage from the shot's. In principle the armor's probability distribution is a function of all possible shots so far. We avoid this by referring to a previous value we know to be correct and updating it iteratively

Because this is pretty hard (conceptually, not computationally, in fact I am still not 100% either of the plaintext summaries is perfect, I just know it can be done mathematically using approximately this algorithm and it did work in my model) it is absolutely necessary to also have a function to generate comparison data using a large number of simulations with random shots to make sure the code is right.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 03, 2022, 01:34:54 AM
Great! But with some errors. Specifically you do not always set it to same as naive, only once.

Because you said to do that and left it out of the loop, as you have below.

Quote
I am not sure what you mean by skip first and last reductions. Let me try to be more specific. Also, this is if you index ship cells from 1 to n. So if they are in a vector from 0 to n-1, then subtract 1 from each index. You do not need to compute expected ADR for the padding cells beyond hittable cells because "hitting" those directly must count as a miss and not do damage, even though they can be damaged indirectly by hitting the first and last armor cells.

Because I understood your previous specification to imply that the expected armor damage reductions should be iterated from second to second-last rather than from first to last.

Quote

Step 0. For the very first step of dealing damage, you apply the naive armor damage reduction value based on the starting armor only.
  Armor = starting armor
  That is also the starting value for the expected armor damage reductions.
   Expected adr= adr calculated from starting armor (a vector containing a separate value for each of the central cells)
Loop:
Step 1. Deal damage to armor and hull based on the previously calculated (at step 3) expected armor damage reduction values for each central cell.
  Keep the expected armor damage reductions saved in a variable that is separate from the armor matrix as they are updated, not re-calculated.

damage distributed over armor around middle cell due to hit at middle cell = damage*adjusted ADR for that middle cell
Damage over armor is calculated as usual, but using the expected adr rather than naive adr. However for step 0 they are the same, hence the special case above and the order in the loop.

Step 2. Compute, based on current armor state, the armor states that result from
  Substep 1. A hit to cell 1...
  Substep n. A hit to cell n, using armor damage reduction computed based on current armor. Save these armor states somewhere.


We use the hypothetical armor states here:
Step 3. Compute expected armor damage reductions for the next step, using the hypothetical armor states computed at step 2 as:
  Substep 1. At cell 1, the expected armor damage reduction is:
  The previous armor damage reduction, times probability that the shot does not land in cells 1 to 5, plus:
    Probability that the shot does land in cell 1, times armor damage reduction at cell 1 if shot lands in cell 1,
    plus probability that the shot lands in cell 2, times armor damage reduction at cell 1 if shot lands in cell 2,
    plus probability that the shot lands in cell 3, times armor damage reduction at cell 1 if the shot lands in cell 3,
    plus probability that the shot lands in cell 4, times armor damage reduction at cell 1 if the shot lands in cell 4.
    plus probability that the shot lands in cell 5, times armor damage reduction at cell 1 if the shot lands in cell 5.

   Compute:
naive ADR 1= hit strength/(hit strength+ pooled armor at cell 1 in hypothetical armor state where cell 1 was hit)
naive ADR 2= hit strength/(hit strength+ pooled armor at cell 1 in hypothetical armor state where cell 2 was hit)
...
naive ADR 5 = hit strength/(hit strength+ pooled armor at cell 1 in hypothetical armor state where cell 5 was hit)
expected ADR= expected ADR*(1-p1-p2-p3-p4-p5)+p1*naive ADR 1+p2*naive ADR2+...+p5*naive ADR 5
Where the p:s are hit probabilities for those cells (ie probability to hit cell 1, probability to hit cell 2, ...)

  Substep k: For a cell in the middle of the armor, it is the analogous calculation as at substep 1
    but you look at the possibilities and armor damage reductions for if the shot were to land in cells
    k-4 to k+4 as those are the cells that would affect armor damage reduction at cell k.
  Substep n: Same, but the cells are n-4 to n.

Alternative: same as above, but to save resources, only compute to middle cell m. Then for rest of the armor mirror expected ADR, so exp ADR n = exp ADR 1, exp ADR n-1 = exp ADR 2 etc.


At no point after step 0 are you allowed to compute expected ADR directly from the armor but instead it must be updated at each step as above

Therein lies the problem because this code is incorrectly indented, from the loop body to the substeps of the "Compute" statement, which itself seems to contradict the preceding one.  I need you to clarify the difference between Step 3 and "Compute" before I can write Python code.  Also, writing for i in range(5): do_this_thing would more clearly and simply express repetition than repeating statements five times.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 03, 2022, 01:41:34 AM
Alright it looks like i'll be unable to get to the computer or latex it for a bit but here's a very rough sketch of the proof. Denote pool(A,k) = pooled armor of armor matrix A at cell k using the pooling method we use. We will ignore the minimum armor and minimum damage functions here due to time limits but they can be applied as usual.

Assume for argument's sake we know chi(t), the vector of armor damage reductions after shots at timepoint t, and E(A_t), the expected state of the armor at timepoint t. We know the probability distribution of shots p where p_i is probability to hit cell i and it is constant. We do not know the probability distribution of armor.

Then, to compute chi_t+1, note expected value of pooled damage (that we will then distribute) at a middle cell k of armor of  E(A_(t+2)) is damage*E(chi_k_t+1) where chi is the ADR function. Per LOTUS we can express this using p as sum of chi_k_t+1 given hit at cell i at timepoint t * p_i. We note that in case of misses this is equal to E(chi_k_t)*probability of miss at timepoint t. And in case of a hit it is equal to hit strength/(hit strength+pool(A_t*i,k))*p_i, where i is the cell hit, and A_t*i is the hypothetical amor state if cell i is hit at timepoint t with certainty which we can compute because we know yhe expected armor state E(A_t) per the assumption.

Now, to compute EA_t+1 means just applying damage to E(A_t) using armor damage reductions E(chi_t) which we know by assumption
.so we can compute E A_t+1 and E chi_t+1 if we know E A_t and E chi_t.

Now note that we know these at step 1, so method follows.

Sorry I know this is not very good. Do latex and explanations later as needed but i thought a quick draft would be helpful. Edit: fix timepoints for chi, clarify. Know it's still pretty bad. Explanatory edit: exprcting family emergency which may distract from Starsector. See if it actualizes.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 03, 2022, 03:29:58 AM
Therein lies the problem because this code is incorrectly indented, from the loop body to the substeps of the "Compute" statement, which itself seems to contradict the preceding one.  I need you to clarify the difference between Step 3 and "Compute" before I can write Python code.  Also, writing for i in range(5): do_this_thing would more clearly and simply express repetition than repeating statements five times.

Oh sorry, I misread what you were doing with the code. Here is an attempt at a better formatted pseudocode courtesy of notepad++. (I did get a chance to get back to the computer momentarily)

(https://i.ibb.co/vXbcwt8/image.png) (https://ibb.co/phg10mp)
Code
# corrected armor damage reduction code

function pool (armor matrix, cell){
   return sum (j from -2 to 2) (i from -2 to 2) armor matrix cell i_j times weight_i_j
}
# function to compute ADR_vector

function ADR (armor matrix, cell) {
return max(minimum damage, hit strength / (hit strength + max(minimum armor fraction * a_0, pool(armor matrix, cell)))
}

damage (armor matrix, cell) {
    loop over armor matrix
deal damage in 5x5 area around cell distributed according to weights / 15 times ADR(armor matrix, cell)
return damaged armor matrix
}

weight =
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

#hit probability distribution for central cells, in reality this will also include misses and be padded but here we will just index from 1 to n
p = vector(probability_mass_at_cell_i, length = n)


# at start, armor is set based on ship starting armor armor_0. In addition there are 2 rows of padding at each end. The actual ship armor cells are numbered from 1 to n.

armor_matrix = matrix(armor_0/15, rows = 5, columns = n+4)

# at start, armor damage reduction is calculated based on armor at start

ADR_vector = vector(hit strength/(hit strength + a_0), length = n)

# to compute next expected ADR we must know previous expected armor state (armor_matrix) and previous expected ADR_vector
function armordamage (armor_matrix, ADR_vector, damage, hit strength, probability vector){
#compute hypotheticals
for (index i over actual ship armor cells from 1 to n)
spawn hypothetical armor matrix armor_star_i = damage( armor matrix, i)
#update armor matrix
armor_matrix = {calculate damage to armor matrix here as usual, hitting each central cell with probability p[i], and use ADR_vector[i] for armor damage reduction}
#update ADR estimates
for (i from 1 to n){
     pooled probability = 0
probability weighted ADR = 0
for (j in (loop over all columns where a hit would affect the ADR; for edges this is 1 to 5 and n-4 to 4; for central cells this is cell-4 to cell +4){
    pooled probability = pooled probability + p[j]
probability weighted ADR = probability weighted ADR + ADR(armor_star_j, at cell i)*p[j]
}
ADR_vector[i] = ADR_vector[i]*(1-pooled_probability) + probability weighted ADR
}
return(armor_matrix, ADR_vector)
}


Edit: fixed error on line 50
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 03, 2022, 05:01:14 AM
And here is a proof sketch.
(https://i.ibb.co/VTPtCcJ/image.png) (https://ibb.co/pd9xKBb)
(https://i.ibb.co/9TsM4S7/image.png) (https://ibb.co/r64WpNV)

Edit to add: missing, but note w_i near the end of page 1 is ith row vector of W
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 03, 2022, 01:17:41 PM
Oh sorry, I misread what you were doing with the code. Here is an attempt at a better formatted pseudocode courtesy of notepad++. (I did get a chance to get back to the computer momentarily)

Edit: fixed error on line 50

You might be surprised, but you have now almost written the Python code yourself.  :)  Here is my partly-implemented and heavily marked-up version of your specification.  Also, please note that to specify an iteration clearly-enough for someone else to implement is to almost write it in Python, for which one would only need to use the following syntax to type valid code:
for element in iterable_object:
    do_stuff
    do_other_stuff
    do_yet_other_stuff

or if you want to work with indices,
for index, element in enumerate(iterable_obejct):
    call_some_function(index)
    call_another_function(element, index)
    call_some_other_function(element)

So you might as well—just remember the 0-indexing.  ;D

You can't escape the Pythooooonnnnn hissssss :p
   
Code
"""
Damage computing module.
"""

WEIGHTS = [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]

MINIMUM_ARMOR_FACTOR = 0.05


def pool(armor_grid: list, index: int) -> float:
    """
    Return the pooled armor for the cell at this index of an armor
    row.
    """
    return sum([[armor_grid[i][j] * weight[i][j] for i in
                range(index - 2, index + 3)] for j in range(-3, 2)])
   
   
def armor_damage_factor(armor_grid: list, index: int) -> float:
    """
    Return the armor damage factor for the cell at this index
    of an armor row.
    """
    #CONCERN: The minimum armor factor in this function affects both armor
    #         and hull damage although I remember reading that this factor
    #         affects only the latter
    armor = max(MINIMUM_ARMOR_FACTOR * armor_rating, pool(armor_grid, index))
return max(minimum_damage, hit_strength / (hit_strength + armor))


def armor_damage(armor_grid, cell):
    """
    loop over armor_grid:
    in 5x5 area around cell:
        deal damage distributed according to weights / 15 times ADR(armor matrix, cell)
    """
    #QUESTION: How do you mean to loop over the armor_grid and 5x5 area?
    return armor_grid


def update_armor(
    #QUESTION: what about the armor row, which is needed, or is it
    #          implicitly the cells after the first two and before
    #          the last two of the third row of armor_grid?
    armor_grid: list,
    armor_damage_factors: list,
    damage: float, #QUESTION: What damage, exactly?  To armor, shields, hull?
    hit_strength: float,
    probabilities: list) -> tuple:
    """
    Update the armor grid and armor damage factors.
    """
    #QUESTION: What variable contains the 'actual ship armor cells'—
    #          are they perhaps the armor row?
    #CONCERN: The armor grid (rather than row) index passed to the
    #         armor damage function was i rather than the padding-
    #         adjusted value i + 2.
    virtual_armor_grids = [armor_damage(armor_grid, i + 2) for i, _ in
                           enumerate(armor_grid[2][-2:2])]
   
    armor_grid = #calculate damage to armor matrix here as usual,
    #hitting each central cell with probability p[i], and use
    #armor_damage_factors[i] for armor damage reduction
   
    #re-estimate the armor damage factors
    for i, _ in enumerate(armor_grid[2][-2:2]):
        pooled_probability = 0
        probable_armor_damage_factor = 0
        #QUESTION: What columns are those?
    for j in #loop over all columns where a hit would affect the ADR:
        #QUESTION: Why each entire edge rather than the 3-long
        #          fringe beside each one?
        #CONCERN: I think you want nested iteration, but how,
        #         exactly?
        for #edges this is 1 to 5 and n-4 to 4:
            for #central cells this is cell-4 to cell+4:
                    pooled_probability += probabilities[j]
            probable_armor_damage_factor += armor_damage_factor(
                armor_star_j, at cell i) * probabilities[j]
    armor_damage_factors[i] = armor_damage_factors[i]
                              * (1 - pooled_probability)
                              + probable_armor_damage_factor
    #COMMENT: No need for a return statement in the Python version
    #         of this code because modifying the elements of a list
    #         by index in the body of a function wherein the list is
    #         an argument, as your specification does, modifies
    #         the original list in-place.  We can do the same for
    #         the armor grid.
    #         
    #         The statefulness of our system and side-effects of
    #         this function on both current and expected values make
    #         me think of applying some kind of Markov-like thinking
    #         to this math and an object-oriented approach to the
    #         code implementing it.


def main():
    #QUESTION: What do you mean by central cells and "in reality",
    #          and why does this code not do what would be done
    #          "in reality"?
    #CONCERN: This variable was just named 'p' rather than a word.
    #         Even in code specifications, single-letter variable
    #         names can confuse readers, so please use descriptive
    #         variable names when specifying code.
    #CONCERN: What is 'probability_mass_at_cell_i', and whence does
    #         it come; e.g., an argument or function?
    #hit probability distribution for central cells. In reality this
    #will also include misses and be padded, but here we will just
    #index from 1 to n.
    probabilities = [probability_mass(cell) for cell in armor_row]
   
    #Initial armor is based on ship armor_rating
    #In addition there are 2 rows of padding at each end. The actual
    #ship armor cells are numbered from 1 to n.
    armor_grid = [[armor_rating / 15 for _ in range(len(armor_row) + 4)] for
                  _ in range(5)]
   
    #calculate armor damage reduction
    armor_damage_factors = [hit strength / (hit strength + armor_rating) for _
                            in armor_row]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 03, 2022, 04:13:34 PM
Well, family thing is happening now with a slight delay, so expect me to be absent for a few days. Quick comments

    #CONCERN: The minimum armor factor in this function affects both armor
    #         and hull damage although I remember reading that this factor
    #         affects only the latter

Per the wiki, the 5% minimum is used in the ADR calculation. However, if Vanshilar or intrinsic_parity could confirm whether it is, that'd be great.

  #QUESTION: How do you mean to loop over the armor_grid and 5x5 area?

In my version of the code, the idea was that this would return a damaged armor matrix that has taken 1 hit.


   #QUESTION: what about the armor row, which is needed, or is it
    #          implicitly the cells after the first two and before
    #          the last two of the third row of armor_grid?

We always calculate armor damage at the middle cell, then distribute. If the dmg function distributes then don't need to loop over rows here. If handled cell by cell then loop over rows too to distribute damage, but the adr calculation is always pooled at the hittable cell.

damage: float, #QUESTION: What damage, exactly?  To armor, shields, hull?

Shot damage to armor.


QUESTION: What variable contains the 'actual ship armor cells'%u2014
    #          are they perhaps the armor row?
    #CONCERN: The armor grid (rather than row) index passed to the
    #         armor damage function was i rather than the padding-


Index as appropriate in the real version to also comtain padding.

By actual ship armor cells I meant the hittable middle cells (not the padding). Only those should have an expected adr value since only they can be hit, and adr calculation happens there.

      #QUESTION: What columns are those?


We only need to calculate adr for the hittable middle cells, however they are indexed in practice, since only those can be hit. The padding cannot be hit directly.

  #QUESTION: Why each entire edge rather than the 3-long
           #          fringe beside each one?

I'm not sure what you mean, but hits up to 5 cells away must be considered because they would affect ADR at the cell we are examining. The rule is consider all hits that would affect pooled armor at the cell. Padding can't be hit directly so a hit there (at the middle cell) doesn't affect adr as it is a miss.

CONCERN: What is 'probability_mass_at_cell_i', and whence does
    #         it come; e.g., an argument or function?

Just probability to hit cell from hit distribution function.

Note that this does not consider hull damage. Get that from the "normal damage" part, no need to compute it for the ADR adjustment calculations.

Also, up to you whether you want to prototype it first without optimizations, but consider including
- an if condition to only update ADR expectation if pooled armor around middle cell is above minimum armor
- compute only to middle cell, get rest by mirroring
As those will make this actually usable in practice and superior over simulating.

Sorry for any errors here, bit of a hurry now. If anything here in these quick comments seems wrong it may be since I just threwe them together. Trust your judgement, I think you got this. If you understand the principle by now (the idea of updating adr expectations by pooling armor from the hypotheticals) then there are many different ways of actually doing that correctly just like there are for the normal armor damage calculation.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 04, 2022, 03:06:54 PM
Code
import copy

"""
Damage computing module.
"""

class ArmorGrid:
    """
    A Starsector ship armor grid.
    """
    _ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    WEIGHTS = [[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]]
   
    def __init__(self, armor_rating: float, cell_size: float, width: int):
        self._minimum_armor = ArmorGrid._MINIMUM_ARMOR_FACTOR * armor_rating
        self._cells = [[armor_rating * ArmorGrid._ARMOR_RATING_PER_CELL_FACTOR
                       for _ in range(width + 4)] for _ in range(5)]
        self._bounds = [i * cell_size for i, _ in enumerate(self[2][2:-2])]
   
    def __getitem__(self, row) -> float:
        """
        Return the armor value of the jth cell of the ith row.
        """
        return self._cells[row]

    def _pool(self, index: int) -> float:
        """
        Return the pooled armor for the cell at this index of an armor
        row.
        """
        return sum([[self[i][j] * ArmorGrid._WEIGHTS[i][j] for i in
                    range(index - 2, index + 3)] for j in range(-3, 2)])
 
    def _effective_armor(self, index: int) -> float:
        """
        Return the effective armor for the cell at this index
        of an armor grid.
        """
        return max(self._minimum_armor, self._pool(index))
       
    def damage_factor(self, hit_strength: float, index: int) -> float:
        """
        Return the armor damage factor for a hit to the cell at
        this index of an armor grid.
        """
        return max(ArmorGrid._MINIMUM_DAMAGE_FACTOR,
               1 / (1 + self._effective_armor(index) / hit_strength))
               
    @property
    def bounds(self):
        """
        The right bound of each cell in the middle row, except the two
        padding cells on both sides.
        """
        return self._bounds


class Hit:
    def __init__(
            self,
            probabilities: list,
            base_damage: float,
            shield_damage_factor: float,
            is_beam: bool):
        self._probabilities = probabilities
        self.base_armor_damage = base_damage / shield_damage_factor
        self.strength = self.base_armor_damage / (2 if is_beam else 1)
       
    def _expected_armor_damage_distribution(self, damage: float) -> list:
        """
        Return the expected damage to each cell of the targeted armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
        return [damage * probability for probability in self._probabilities]
       
    def weighted_expected_armor_damage_distributions(
            self,
            damage: float) -> list:
        """
        Return the weighted distribution across the surrounding armor grid
        of the expected damage to each cell of the targeted armor row.
       
        damage - damage against armor after reduction by armor
        """
        return [
            [[damage * weight for weight in row] for row in ArmorGrid.WEIGHTS]
             for damage in self._expected_armor_damage_distribution(damage)
        ]
                   
    def damage_armor_grid(self, armor_grid: object, armor_damage_factors: list):
        for i, distribution in enumerate(
            self.weighted_expected_armor_damage_distributions(
                self.base_armor_damage)):
            for j, row in enumerate(distribution):
                for k, damage in enumerate(row):
                    armor_grid[j][i+k] = max(0, armor_grid[j][i+k] - damage)


def armor_damage_factors(armor_grid: object, hit_strength: float) -> list:
    """
    Return the armor grid and armor damage factors.
    """
    return [armor_grid.damage_factor(hit_strength, i + 2) for i, _ in
            enumerate(armor_grid[-2:2])]


def hit_probability(bound: float): return 0.1 #dummy for test


def main():
    armor_rating, cell_size, width = 100, 10, 10
    base_damage, shield_damage_factor, is_beam = 10, 1, False
   
    armor_grid = ArmorGrid(armor_rating, cell_size, width)
    probabilities = [hit_probability(bound) for bound in armor_grid.bounds]
    hit = Hit(probabilities, base_damage, shield_damage_factor, is_beam)
    damage_factors = [hit.strength / (hit.strength + armor_rating) for _ in
                      armor_grid[2:-2]]
   
    for row in armor_grid: print([round(x) for x in row])
   
    hit.damage_armor_grid(armor_grid, damage_factors)
    damage_factors = armor_damage_factors(armor_grid, hit.strength)
   
    print()
   
    for row in armor_grid: print([round(x) for x in row])
main()
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]

[7, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 7]
[6, 5, 4, 3, 3, 3, 3, 3, 3, 3, 3, 4, 5, 6]
[6, 5, 4, 3, 3, 3, 3, 3, 3, 3, 3, 4, 5, 6]
[6, 5, 4, 3, 3, 3, 3, 3, 3, 3, 3, 4, 5, 6]
[7, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 7]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 07, 2022, 01:59:20 AM
All right, seems like that is a basic armor damage calculation right? Good to have it working.

This line seems incorrect
Code
        self.strength = self.base_armor_damage / (2 if is_beam else 1)
since for beams, the damage might be e.g. 1 if we happen to have just 1 tick of a charging down beam about to vanish hitting the armor during the second, but hit strength will still be fixed DPS/2. So for beams a hit strength parameter needs to be passed down separately (or calculated from the fact that DPS = beam tick damage * 10).

I had some problems understanding the rest of it. Does it implement the error correction? I do not think I see the infrastructure for it.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 07, 2022, 11:59:40 AM
Code
import copy

"""
Damage computing module.
"""

class ArmorGrid:
    """
    A Starsector ship armor grid.
    """
    _ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    WEIGHTS = [[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]]
   
    def __init__(self, armor_rating: float, cell_size: float, width: int):
        self._minimum_armor = ArmorGrid._MINIMUM_ARMOR_FACTOR * armor_rating
        self._cells = [[armor_rating * ArmorGrid._ARMOR_RATING_PER_CELL_FACTOR
                       for _ in range(width + 4)] for _ in range(5)]
        self._bounds = [i * cell_size for i, _ in enumerate(self[2][2:-2])]
   
    def __getitem__(self, row) -> float:
        """
        Return the armor value of the jth cell of the ith row.
        """
        return self._cells[row]

    def _pool(self, index: int) -> float:
        """
        Return the pooled armor for the cell at this index of an armor
        row.
        """
        return sum([sum([self[j][i + index - 3] * ArmorGrid.WEIGHTS[j][i]
                         for i in range(0, 5)]) for j in range(0, 5)])
 
    def _effective_armor(self, index: int) -> float:
        """
        Return the effective armor for the cell at this index
        of an armor grid.
        """
        return max(self._minimum_armor, self._pool(index))
       
    def damage_factor(self, hit_strength: float, index: int) -> float:
        """
        Return the armor damage factor for a hit to the cell at
        this index of an armor grid.
        """
        return max(ArmorGrid._MINIMUM_DAMAGE_FACTOR,
               1 / (1 + self._effective_armor(index) / hit_strength))
               
    @property
    def bounds(self):
        """
        The right bound of each cell in the middle row, except the two
        padding cells on both sides.
        """
        return self._bounds
       
       
class Target:
    """
    Holds an armor grid and potentially a shield and hull.
    """
    def __init__(self, armor_grid):
        self.armor_grid = armor_grid


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

    base_shield_damage - starting amount of damage to be
                         inflicted on the target shield
    base_armor_damage - starting amount of damage to be
                        inflicted on the target armor
    strength - strength against armor for armor damage
               calculation
    """
    def __init__(
            self,
            base_shield_damage: float,
            base_armor_damage: float,
            strength: float):
        self.base_shield_damage = base_shield_damage
        self.base_armor_damage = base_armor_damage
        self.strength = strength


class DamageExpectation:
    """
    Expected damage to a target by a shot.

    Calculates the expectation value of the damage of a shot
    with a spread to a target with a shield, armor grid, and random
    positional deviation. 

    shot - what is being fired, whether a projectile, missile, or beam tick
    target - what is being hit
    distribution - spread of shots across a horizontal distance
    """
    def __init__(self, target: object, shot: object, distribution: object):
        self.shot = shot
        self.target = target
        self.probabilities = [distribution(bound) for bound in
                              target.armor_grid.bounds]
   
    def _expected_armor_damage_distribution(self) -> list:
        """
        Return the expected damage to each cell of the targeted armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
       
        return [self.shot.base_armor_damage
                * self.target.armor_grid.damage_factor(self.shot.strength, i+2)
                * self.probabilities[i]
                for i, _ in enumerate(self.probabilities)]
       
    def _weighted_expected_armor_damage_distributions(self) -> list:
        """
        Return the weighted distribution across the surrounding armor grid
        of the expected damage to each cell of the targeted armor row.
       
        damage - damage against armor after reduction by armor
        """
        return [
            [[damage * weight for weight in row] for row in ArmorGrid.WEIGHTS]
             for damage in self._expected_armor_damage_distribution()
        ]

    def damage_armor_grid(self):
        """
        Reduce the values of the armor grid cells of the target
        by the expected value of the damage of the shot across them.
        """
        for i, distribution in enumerate(
            self._weighted_expected_armor_damage_distributions()):
            for j, row in enumerate(distribution):
                for k, damage in enumerate(row):
                    self.target.armor_grid[j][i+k] = (
                        max(0, self.target.armor_grid[j][i+k] - damage))


def hit_probability(bound: float): return 0.1 #dummy for test


def main():
    armor_rating, cell_size, width = 100, 10, 10
    base_armor_damage, base_shield_damage, hit_strength = 10, 40, 10
   
    target = Target(ArmorGrid(armor_rating, cell_size, width))
    shot = Shot(base_armor_damage, base_shield_damage, hit_strength)
    expectation = DamageExpectation(target, shot, hit_probability)
   
    for row in expectation.target.armor_grid: print([round(x) for x in row])
   
    expectation.damage_armor_grid()
   
    print()
   
    for row in expectation.target.armor_grid: print([round(x) for x in row])
main()
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]

[7, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7]
[6, 6, 5, 5, 4, 4, 4, 4, 4, 4, 5, 5, 6, 6]
[6, 6, 5, 5, 4, 4, 4, 4, 4, 4, 5, 5, 6, 6]
[6, 6, 5, 5, 4, 4, 4, 4, 4, 4, 5, 5, 6, 6]
[7, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7]


I have discovered that the code was not even calling the right methods and have refactored it, even removing the armor_damage_factors.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: pponmypupu on December 08, 2022, 10:48:36 AM
It is interesting to note that among medium guns, older Collapse-era technology has a surprising advantage, as the Arbalest resulted in slightly faster kills than newer weapons, despite accuracy problems that were included in the model. However, we note that this comes at a range disadvantage that may not be justified by the slightly faster TTK.

this is really interesting and surprising to me. the arbalest to me has always been a weapon of efficiency but not effectiveness. when testing mediums out was there anything in the large slots or how was the ttk measured as the arbalest/heavy needler are kinetics only? what do you think is the reason behind the arbalest's performance?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 08, 2022, 11:46:17 AM
All slots were always filled. You can view the full table of weapon combinations and associated scores at https://pastebin.com/Yvrftsw1

A couple of things to note here.
1. While the Arbalest performed well on average, note that the strongest combos did not have Arbalests, and especially not double Arbalest. The numbers for single weapons are the average ttk for layouts that have this weapon. That is, "what is the average ttk for this weapon, when other equipment is random". It turns out Arbalest scores well with that metric, but must be aware of this nuance. As for why, my guess is because it is a kinetic weapon with better DPS than HVD and better damage/shot and accuracy than HAC.
2. However, note that further in the thread, Vanshilar simulated the combos and when paired with longer range weapons the AI simply did not fire the Arbalest most of the time, making it a pretty poor choice in reality unless you compensate for this somehow.
3. Not all medium ballistics were included, for example the HMG would likely have outperformed Arbalest had it been in, when  range is ignored in the model. Energy weapons were also absent. We are working on a more accurate model currently, having found some things to improve on, and hopefully a tool to provide custom and/or comprehensive tests.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 08, 2022, 09:18:47 PM
I want your feedback for the code I posted and, if it is good, to start writing unit and integration tests that would verify that it does.  The unit tests would cover functions with tricky math, while the integration tests would see how the whole arrangement works together.  I need your help to decide what numbers the test should start from and to determine what results it should yield.  I figure the integration test should cover high and low damage, all four damage types, and both individual and successive shots.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 08, 2022, 09:45:57 PM
All right, let's see:


import copy

"""
Damage computing module.
"""

class ArmorGrid:
    """
    A Starsector ship armor grid.
    """
    _ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    WEIGHTS = [[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]]
   
    def __init__(self, armor_rating: float, cell_size: float, width: int):
        self._minimum_armor = ArmorGrid._MINIMUM_ARMOR_FACTOR * armor_rating
        self._cells = [[armor_rating * ArmorGrid._ARMOR_RATING_PER_CELL_FACTOR
                       for _ in range(width + 4)] for _ in range(5)]
        self._bounds = [i * cell_size for i, _ in enumerate(self[2][2:-2])]
   
    def __getitem__(self, row) -> float:
        """
        Return the armor value of the jth cell of the ith row.
        """
        return self._cells[row]

    def _pool(self, index: int) -> float:
        """
        Return the pooled armor for the cell at this index of an armor
        row.
        """
        return sum([sum([self[j][i + index - 3] * ArmorGrid.WEIGHTS[j]
                         for i in range(0, 5)]) for j in range(0, 5)])
 
    def _effective_armor(self, index: int) -> float:
        """
        Return the effective armor for the cell at this index
        of an armor grid.
        """
        return max(self._minimum_armor, self._pool(index))
       
    def damage_factor(self, hit_strength: float, index: int) -> float:
        """
        Return the armor damage factor for a hit to the cell at
        this index of an armor grid.
        """
        return max(ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                  1 / (1 + self._effective_armor(index) / hit_strength))
                  
Everything seems correct so far, if I understand the code right.

    @property
    def bounds(self):
        """
        The right bound of each cell in the middle row, except the two
        padding cells on both sides.
        """
        return self._bounds
       
       
class Target:
    """
    Holds an armor grid and potentially a shield and hull.
    """
    def __init__(self, armor_grid):
        self.armor_grid = armor_grid
If this is intended to be an object describing the ship, shouldn't it hold an armor grid, soft flux, hard flux, and hull?

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

    base_shield_damage - starting amount of damage to be
                         inflicted on the target shield
    base_armor_damage - starting amount of damage to be
                        inflicted on the target armor
    strength - strength against armor for armor damage
               calculation
    """
    def __init__(
            self,
            base_shield_damage: float,
            base_armor_damage: float,
            strength: float):
        self.base_shield_damage = base_shield_damage
        self.base_armor_damage = base_armor_damage
        self.strength = strength

Also for the fully integrated code needs the type parameter (beam vs. other) to know whether we are dealing soft or hard flux and how to calculate
 damage (because for beams we should hand the function a real number describing beam ticks and beam intensity during that second, while for a gun
 type weapon we should hand the function an integer describing how many shots hit the target during that second)

Occurs to me: are there beam weapons in mods that deal hard flux? If so, then it actually needs two modifiers: one for flux type and one for weapon type

class DamageExpectation:
    """
    Expected damage to a target by a shot.

    Calculates the expectation value of the damage of a shot
    with a spread to a target with a shield, armor grid, and random
    positional deviation. 

    shot - what is being fired, whether a projectile, missile, or beam tick
    target - what is being hit
    distribution - spread of shots across a horizontal distance
    """
    def __init__(self, target: object, shot: object, distribution: object):
        self.shot = shot
        self.target = target
        self.probabilities = [distribution(bound) for bound in
                              target.armor_grid.bounds]
   
    def _expected_armor_damage_distribution(self) -> list:
        """
        Return the expected damage to each cell of the targeted armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
       
        return [self.shot.base_armor_damage
                * self.target.armor_grid.damage_factor(self.shot.strength, i+2)
                * self.probabilities
                for i, _ in enumerate(self.probabilities)]
       
    def _weighted_expected_armor_damage_distributions(self) -> list:
        """
        Return the weighted distribution across the surrounding armor grid
        of the expected damage to each cell of the targeted armor row.
       
        damage - damage against armor after reduction by armor
        """
        return [
            [[damage * weight for weight in row] for row in ArmorGrid.WEIGHTS]
             for damage in self._expected_armor_damage_distribution()
        ]

    def damage_armor_grid(self):
        """
        Reduce the values of the armor grid cells of the target
        by the expected value of the damage of the shot across them.
        """
        for i, distribution in enumerate(
            self._weighted_expected_armor_damage_distributions()):
            for j, row in enumerate(distribution):
                for k, damage in enumerate(row):
                    self.target.armor_grid[j][i+k] = (
                        max(0, self.target.armor_grid[j][i+k] - damage))


def hit_probability(bound: float): return 0.1 #dummy for test

Seems correct overall, but does not appear to implement error correction.
def main():
    armor_rating, cell_size, width = 100, 10, 10
    base_armor_damage, base_shield_damage, hit_strength = 10, 40, 10
   
    target = Target(ArmorGrid(armor_rating, cell_size, width))
    shot = Shot(base_armor_damage, base_shield_damage, hit_strength)
    expectation = DamageExpectation(target, shot, hit_probability)
   
    for row in expectation.target.armor_grid: print([round(x) for x in row])
   
    expectation.damage_armor_grid()
   
    print()
   
    for row in expectation.target.armor_grid: print([round(x) for x in row])
main()
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]

[7, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7]
[6, 6, 5, 5, 4, 4, 4, 4, 4, 4, 5, 5, 6, 6]
[6, 6, 5, 5, 4, 4, 4, 4, 4, 4, 5, 5, 6, 6]
[6, 6, 5, 5, 4, 4, 4, 4, 4, 4, 5, 5, 6, 6]
[7, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7]


I have discovered that the code was not even calling the right methods and have refactored it, even removing the armor_damage_factors.


I would actually recommend first writing a test module to test the armor damage code versus a version of the code where you use the same functions to simulate dealing damage to Dominators 1 shot at a random cell at a time (using the same probability distribution as you do in the model), like I did with the "hull residual plots" above. 500 energy damage seems to be a good spot for detecting errors in the calculation and has the advantage that you have both intrinsic_parity's and my simulation data to test against to make sure the error is in the same range. Printing a "hull residual plot" (plot observed hull minus expected hull from model for a large number of sims) demonstrates the error in the model very cleanly. An alternative is an "armor residual plot" which should avoid any statistical weirdness leading to a bulge at the start, but also does not demonstrate that the hull damage computation is going right. Testing whether damage type modifiers are applied correctly should be almost trivial since they are just simple multipliers, but you can do that here by fiddling with the damage type and see that graphs change as expected.

Then should build the integrated code, with this specific weapons layout: Locust Locust Harpoon Harpoon Gauss HAG, and getting it to print graphs for flux, hull damage and armor damage. This is because we have actual real life sim data for what the numbers should be: https://fractalsoftworks.com/forum/index.php?topic=25459.msg379103#msg379103

Then when we have that we can test that the overall code is working correctly (ignoring exact numbers, but seeing that the results are broadly similar with some fiddling of the SD factor).

Then after that, build the advanced error corrected armor damage module and test it versus the simulated Dominators from the first step to see that it improves the prediction. If your code runs very fast you could do what I dreamed of doing but never did, and compute the average hull residual at point of kill for each combination of weapon damage from 50 to 1000 and armor from 150 to 1750 for both the error corrected and the naive model, and make a nice plot to make sure it consistently improves predictions over the entire spectrum. That's optional though, we know it should be more correct mathematically so if it improves prediction for a few "hard" cases (ie. ones where damage is reasonably low but not so low that it is always reduced to minimum, which is where we expect non-linearity to be greatest) then that should be enough.

After that, it would probably be reasonable to test the beam weapon code vs. real life by running a similar simulation as Vanshilar did using, say, Tachyon Lances. Then if that's correct I'd say internal and external validity have been reasonably tested, enough to publish at least.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 08, 2022, 10:11:33 PM
A couple thoughts on the structure of the code:

Instead of having target class, call it a 'ship' class, and potentially allow it to have info about weapons and flux as well as defenses. I think a very important aspect of a good simulation is that it should be symmetric (i.e. simulating both the friendly and enemy ships shooting and getting shot). The shield dynamics cannot be effectively simulated without considering both ships firing and getting hit.

Also, personally, I would not have a separate 'expected damage' class. I would wrap that all up as methods in the armor grid class.

Like hector said, the shot class also is not capable of handling beams. You also need a hull damage value for the shot, which is really a base damage value.

You also need to deal with hull damage somewhere. I might do it as methods in the ship class, but I think you will need to add some stuff to the armor class to deal with overkill damage to armor (when incoming damage exceeds remaining armor in a certain cell the overkill gets dealt to hull) and residual armor. Idk what the easiest way to handle it is.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 08, 2022, 10:15:51 PM
If you are aiming to build the error corrected model eventually then the place for hull damage would be that you compute overkill damage from the final (non-hypothetical) damage to armor and then deal that much damage to hull, adjusted for modifier. Of course there are surely many other ways to do it, but that seems natural.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 09, 2022, 05:54:09 PM
Before implementing such fancier features as error correction or deeper abstraction, I want to ensure that the code is understandable and well-formed and therefore have addressed the concerns you both had about clarity and organization.  The code now has a ship class, which has a weapons attribute, and the shot is now in a weapon with a hit distribution, which I still must implement beyond a dummy flat value.  The testing ideas you suggested will be good later: for now I want to verify that each call of the armor-grid damaging function yields the expected result. 

I have noticed that my code does not determine whether the ship would have its shields up or down or a method to determine whether a ship would fire its high explosive weapons or not; e.g., a Hammerhead might fire Heavy Autocannons until the enemy drops its shield and then fire Harpoons to destroy its armor, but the enemy might leave its shield down to absorb the Heavy Autocannon shots with its armor instead.
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]

[7, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7]
[6, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 5, 6, 6]
[6, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 5, 6, 6]
[6, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 5, 6, 6]
[7, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7]
Code
Code
import copy
"""
Damage computing module.
"""
class ArmorGrid:
    """
    A Starsector ship armor grid.
    """
    _ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    WEIGHTS = [[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]]
   
    def __init__(self, armor_rating: float, cell_size: float, width: int):
        self._minimum_armor = ArmorGrid._MINIMUM_ARMOR_FACTOR * armor_rating
        self._cells = [[armor_rating * ArmorGrid._ARMOR_RATING_PER_CELL_FACTOR
                       for _ in range(width + 4)] for _ in range(5)]
        self._bounds = [i * cell_size for i, _ in enumerate(self[2][2:-2])]
   
    def __getitem__(self, row) -> float:
        """
        Return the armor value of the jth cell of the ith row.
        """
        return self._cells[row]

    def _pool(self, index: int) -> float:
        """
        Return the pooled armor for the cell at this index of an armor
        row.
        """
        return sum([sum([self[i][j + index - 3] * ArmorGrid.WEIGHTS[i][j]
                         for j in range(0, 5)]) for i in range(0, 5)])
 
    def _effective_armor(self, index: int) -> float:
        """
        Return the effective armor for the cell at this index
        of an armor grid.
        """
        return max(self._minimum_armor, self._pool(index))
       
    def damage_factor(self, hit_strength: float, index: int) -> float:
        """
        Return the armor damage factor for a hit to the cell at
        this index of an armor grid.
        """
        return max(ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                  1 / (1 + self._effective_armor(index) / hit_strength))

    @property
    def bounds(self):
        """
        The right bound of each cell in the middle row, except the two
        padding cells on both sides.
        """
        return self._bounds
       
       
class Ship:
    """
    Holds an armor grid and potentially a shield and hull.
   
    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
                       without actively venting
    """
    def __init__(self,
            weapons: object,
            armor_grid: object,
            hull: float,
            flux_capacity: float,
            flux_dissipation: float):
        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. 

    shot - what is being fired: a projectile, missile, or beam tick
    ship - vessel being hit
    distribution - spread of shots across a horizontal distance

    base_damage - amount listed under damage in weapon_data.csv
    base_shield_damage - starting amount of damage to be inflicted
                         on the ship shield
    base_armor_damage - starting amount of damage to be inflicted
                        on the ship armor
    strength - strength against armor for armor damage calculation
    """
    def __init__(
            self,
            base_damage: float,
            base_shield_damage: float,
            base_armor_damage: float,
            strength: float,
            flux_hard: bool):
        self.base_shield_damage = base_shield_damage
        self.base_armor_damage = base_armor_damage
        self.strength = strength
        self.probabilities = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a
        row and save the probabilities for later calculation.
        """
        self.probabilities = [distribution(bound) for bound in
                              ship.armor_grid.bounds]
   
    def _expected_armor_damage_distribution(self, ship: object) -> list:
        """
        Return the expected damage to each cell of the shiped armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
       
        return [self.base_armor_damage
                * ship.armor_grid.damage_factor(self.strength, i+2)
                * self.probabilities[i]
                for i, _ in enumerate(self.probabilities)]
       
    def _weighted_expected_armor_damage_distributions(
            self,
            ship: object) -> list:
        """
        Return the weighted distribution across the surrounding armor grid
        of the expected damage to each cell of the shiped armor row.
       
        damage - damage against armor after reduction by armor
        """
        return [
            [[damage * weight for weight in row] for row in ArmorGrid.WEIGHTS]
             for damage in self._expected_armor_damage_distribution(ship)
        ]

    def damage_armor_grid(self, ship):
        """
        Reduce the values of the armor grid cells of the ship
        by the expected value of the damage of the shot across them.
        """
        for i, distribution in enumerate(
            self._weighted_expected_armor_damage_distributions(ship)):
            for j, row in enumerate(distribution):
                for k, damage in enumerate(row):
                    ship.armor_grid[j][i+k] = (
                        max(0, ship.armor_grid[j][i+k] - damage))
                       
                       
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.
   
    shot - projectile, missile, or beam tick of the weapon
    distribution - function returning the probability that
                   of the shot to hit between two bounds
    """
    def __init__(self, shot: object, distribution: object):
        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_armor_grid(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_probability(bound: float): return 0.1 #dummy for test


def main():
    ship_spec = (
        1_000,#hull
        1_000,#flux_capacity
        100#flux_dissipation
    )
    armor_grid_spec = (
        100,#armor_rating
        10,#cell_size
        10#width
    )
    weapons = []
    shot_spec = (
        20,#base_damage,
        10,#base_shield_damage
        40,#base_armor_damage
        20,#strength
        False#flux_hard
    )
   
    ship = Ship(weapons, ArmorGrid(*armor_grid_spec), *ship_spec)
    weapon = Weapon(Shot(*shot_spec), hit_probability)
   
    for row in ship.armor_grid: print([round(x) for x in row])
   
    weapon.fire(ship)
   
    print()
   
    for row in ship.armor_grid: print([round(x) for x in row])
main()
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 09, 2022, 06:57:51 PM
If you possibly want to add support for realistic combat at some point then could also include a parameter for range. I am not sure if this is something we want to do, I think you had a good point earlier about preferring to just see how weapons work without a lot of ifs.

However it would be easy to implement a rudimentary "AI" such that
- both ships have a top speed and acceleration
- in combat, jink randomly (see stuff I posted earlier)
- when near max flux, increase range until beyond enemy max range, vent if you can actually get beyond max range
- when near 0 flux get back to range where all of your guns fire

Strictly rules based so should not actually burden calculations much, the big q is does it make the model more or less useful for modders and research. It is not necessarily good to have more assumptions. If adding features like AI simulation or enemy ship having weapons then at least there should definitely be a switch to turn it off for people who just want basic weapon damage data.

Does the new code use numpy? Performance should be essential here at least for the armor damage part.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 09, 2022, 07:36:25 PM
If you possibly want to add support for realistic combat at some point then could also include a parameter for range. I am not sure if this is something we want to do, I think you had a good point earlier about preferring to just see how weapons work without a lot of ifs.

However it would be easy to implement a rudimentary "AI" such that
- both ships have a top speed and acceleration
- in combat, jink randomly (see stuff I posted earlier)
- when near max flux, increase range until beyond enemy max range, vent if you can actually get beyond max range
- when near 0 flux get back to range where all of your guns fire

Strictly rules based so should not actually burden calculations much, the big q is does it make the model more or less useful for modders and research. It is not necessarily good to have more assumptions. If adding features like AI simulation or enemy ship having weapons then at least there should definitely be a switch to turn it off for people who just want basic weapon damage data.

Good point.  Maybe we could do all that later.

Quote
Does the new code use numpy? Performance should be essential here at least for the armor damage part.

The code I just posted does not yet use numpy because I want you to tell me if it yields the right numbers or not before I profile it and where needed add the numpy code, which can also be harder to read.

Edit: Here it is with numpy.  I ironically found it clearer to read.  This code is also vectorized for what it's worth.
[[7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]
 [7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]
 [7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]
 [7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]
 [7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7. 7.]]

[[7. 6. 6. 6. 6. 6. 6. 6. 6. 6. 6. 6. 6. 7.]
 [6. 6. 5. 4. 4. 4. 4. 4. 4. 4. 4. 5. 6. 6.]
 [6. 6. 5. 4. 4. 4. 4. 4. 4. 4. 4. 5. 6. 6.]
 [6. 6. 5. 4. 4. 4. 4. 4. 4. 4. 4. 5. 6. 6.]
 [7. 6. 6. 6. 6. 6. 6. 6. 6. 6. 6. 6. 6. 7.]]
Code
Code
import numpy as np
import copy
"""
Damage computing module.
"""
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:
    _ARMOR_RATING_PER_CELL_FACTOR - multiply by armor rating to determine
                                    initial value of each cell of an
                                    ArmorGrid
    _MINIMUM_ARMOR_FACTOR - multiply by armor rating to determine
                            minimum effective value of armor for damage
                            calculations
    _MINIMUM_DAMAGE_FACTOR - least factor whereby this ArmorGrid can
                             cause incoming damage to be multiplied
    WEIGHTS - multiply by damage to the central cell to determine damage
              to surrounding ones
    """
    _ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    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]])
   
    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 of each armor cell, which is a square, in pixels
        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, len(self.cells[2,2:-2])) * cell_size
                 
    def damage_factors(self, hit_strength: float) -> object:
        """
        Return the factor whereby to multiply the damage of each
        non-padding cell of the grid.
        """
        pooled_armor = np.array(
            [np.sum(ArmorGrid.WEIGHTS * self.cells[0:5,index:index+5]) for
             index, _ in enumerate(self.cells[2,2:-2])]
        )
       
        pooled_armor[pooled_armor<self._minimum_armor] = self._minimum_armor
        factors = 1 / (1 + pooled_armor / hit_strength)
        factors[factors<ArmorGrid._MINIMUM_DAMAGE_FACTOR] = (
            ArmorGrid._MINIMUM_DAMAGE_FACTOR)
        return factors

    @property
    def bounds(self):
        """
        The right bound of each cell in the middle row, except the two
        padding cells on both sides.
        """
        return self._bounds
       
       
class Ship:
    """
    Holds an armor grid and potentially a shield and hull.
   
    methods:
    - will_overload
    - overloaded
    - shield_up
    """
    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
                           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.
   
    methods:
    - distribute
    - _damage_distribution
    - damage_armor_grid
    """
    def __init__(
            self,
            base_damage: float,
            base_shield_damage: float,
            base_armor_damage: float,
            strength: float,
            flux_hard: bool):
        """
        base_damage - amount listed under damage in weapon_data.csv
        base_shield_damage - starting amount of damage to be inflicted
                             on the ship shield
        base_armor_damage - starting amount of damage to be inflicted
                            on the ship armor
        strength - strength against armor for armor damage calculation
        flux_hard: whether the flux damage against shields is hard or not
        """
        self.base_shield_damage = base_shield_damage
        self.base_armor_damage = base_armor_damage
        self.strength = strength
        self.probabilities = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a
        row and save the probabilities for later calculation.
        """
        self.probabilities = np.vectorize(distribution)(ship.armor_grid.bounds)
   
    def _damage_distribution(self, ship: object) -> list:
        """
        Return the expected damage to each cell of the shiped armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
        return (self.base_armor_damage
                * self.probabilities
                * ship.armor_grid.damage_factors(self.strength))

    def damage_armor_grid(self, ship):
        """
        Reduce the values of the armor grid cells of the ship
        by the expected value of the damage of the shot across them.
        """
        for i, damage in enumerate(self._damage_distribution(ship)):
            ship.armor_grid.cells[0:5,i:i+5] -= damage * ArmorGrid.WEIGHTS
        ship.armor_grid.cells[ship.armor_grid.cells < 0.0] = 0.0
                       
                       
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_armor_grid(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_probability(bound: float): return 0.1 #dummy for test


def main():
    ship_spec = (
        1_000,#hull
        1_000,#flux_capacity
        100#flux_dissipation
    )
    armor_grid_spec = (
        100,#armor_rating
        10,#cell_size
        10#width
    )
    weapons = []
    shot_spec = (
        20,#base_damage,
        10,#base_shield_damage
        40,#base_armor_damage
        20,#strength
        False#flux_hard
    )
   
    ship = Ship(weapons, ArmorGrid(*armor_grid_spec), *ship_spec)
    weapon = Weapon(Shot(*shot_spec), hit_probability)
   
    print(np.round(ship.armor_grid.cells))
    weapon.fire(ship)
    print()
    print(np.round(ship.armor_grid.cells))
main()
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 09, 2022, 09:26:17 PM
Deleted - error in calculations
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 09, 2022, 11:06:24 PM
Oh ok. I didn't realize you wanted feedback on the print too.

This should be easy to math out without reference to any implementation.

Then would you please work an example, from starting numbers to resulting grid, for me to test against?

Quote
You have 7 armor per cell, so the ship's starting armor value was 105. (I think these should be of type double, since it looks like you specified 100 armor? They must be real numbers anyway because the incoming damage is. Even if the game were to store them as integers in reality, they must be reals for dealing with probability in our model.)

I think you didn't read the main function, which spells out all the data.  The ship's starting armor was 100.  I rounded the numbers when printing them.

Quote
The pooled armor value was 105 at each of the middle cells. Hit strength was 10, so for a shot of 10 damage we expect minimum damage to kick in and do 1.5 damage spread according to the probability distribution of 10% chance to hit each cell. So that doesn't seem correct? Were those the values used?

Presumably they were used, yes.  Are you worried that they weren't?

Quote
This will be easier if you post the shot strength, damage type and ship parameters and probability distribution alongside the print as I may not understand the code correctly.

See my earlier point about the main function.

Quote
The print you posted does seem like an appropriate damage distribution and we can guess the parameters as follows: one cell in the middle takes 3 damage. Presumably probability was uniform so 1/14 chance to hit each cell. Given shot hit strength and damage x, the middle cell should have taken x^2/(x+105)*(1/15*1/14*3+1/30*1/14*2) damage. Solving for x we get a shot damage and hit strength of approximately 230. The edge cell of armor should then take 230/(335)*230*(1/15*1/14*2+1/30*1/14)=1.85 damage so that is correct. The edge cells at top and bottom should take 230/(335)*230*(3*1/30*1/14)=1.12 damage so that is correct. And the padding cells at the very edge should take 230/(335)*230*(1/30*1/14), so 0.38 damage. Which may be correct if it's rounding up.

Probability was uniform, but only the inner cells of the middle row (NumPy array slice of [2,2:-2]) 'received' a probability to hit.

Quote
A simple way to test whether this is going correctly is, for 1 shot vs undamaged armor, pool the armor back after damaging it and see that the total armor damage dealt to pooled armor is equal to hit strength/(hit strength + starting armor)*damage * hit prob. This literally must be so for the first shot when armor is uniform, though not later. Generally that relationship should hold while we are above minimum armor rule and no cells are hitting 0.

That test seems good, though I still want a worked example because I could mess that code up, too.

Quote
This would be a good point to add a "total armor damage dealt" function, as that is needed to test the model later anyway.  The way it works out is that should just be a mean of all armor cells (including padding, but not including corners!) * 15.

I'll add it after we do this.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 01:21:29 AM
Deleted - error in calculations
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 03:59:59 AM
All right so it turns out I was wrong, you cannot expect the damage dealt to armor to be equal to decrease in armor HP even for the first shot (in the sense of calculating a total armor HP as if we were to distribute it equally to get the same average armor cell value). It's just not the way it works. I'm going to delete the posts above so as not to confuse people.

Instead, the test calculation that you can make is that, for the first shot, it should be the case that the expected damage you are going to distribute over the armor should be the same as you would get from calculating the whole thing in bulk, because armor is uniform and probs sum to 1. Ie. sum of damage distributed over armor = damage * hit strength/(hit strength + armor). But this is accurate for the first shot only, since after that considering the armor in bulk is no longer accurate and there is no simple way to restore a full armor hp as a description of the armor!

An armor HP gauge should still exist for comparisons with real life data, it just must be understood that it does not accurately reflect the state of the armor but rather what the enemy ship captain would see (armor can be at half and ship still be taking hull damage as we all know).

Anyway I created this "dumb" and very verbose version of the basic naive armor damage code for a "reference". All the operations are implemented using loops and elementary operations, so it should be quite easy to see that they are correct.

It seems like damage is indeed much higher in your code than it should be. Question: when you write
Code
            ship.armor_grid.cells[0:5,i:i+5] -= damage * ArmorGrid.WEIGHTS

are you accounting for the fact that when distributing damage, the weights should be 1/15th of those used to pool armor? So that it sums up to 1 and not 15. (because for armor we have already applied the 1/15th when generating the armor matrix, so if we sum it to a total of 15 we get back the starting armor, but we haven't done that for damage so if we use the 1/2 and 1 weights we end up with 15 x too high damage)

Code
damage <- 20
hitstrength <- 40
startingarmor <- 100
hitstrengthcalc <- function(hitstrength, armor) return(max(0.15,hitstrength/(hitstrength+max(armor,0.05*startingarmor))))
shipcells <- 10
probabilities <- c(0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1)
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)
weights

poolarmor <- function(armormatrix, index) {
  sum <- 0
  for(i in 1:5)for(j in 1:5) sum <- sum + weights[i,j]*armormatrix[i,index-3+j]
  return(sum)
}
print("Starting armor matrix")
print(armormatrix <- matrix(startingarmor / 15, 5, shipcells+4))

print("Equivalent armor hp:")
fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
print(fullarmorhp)

for(r in 1:norounds){

armordamagereductions <- vector(shipcells, mode="double")
for (x in 1:length(armordamagereductions)) {
  armordamagereductions[x] <- hitstrengthcalc(hitstrength,poolarmor(armormatrix,x+2))
}
print("Armor damage expected based on full armor hp:")
print(damage*hitstrength/(hitstrength+fullarmorhp))

armordamagesatmiddlecells <- armordamagereductions*damage
print("Armor damage at middle cells given full shot:")
print(armordamagesatmiddlecells)
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(round(armormatrix,2))

print("Equivalent armor hp:")
fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
print(fullarmorhp)
}

Output: using

damage <- 20
hitstrength <- 40
startingarmor <- 100
shipcells <- 10
probabilities <- c(0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1)
norounds <- 3



[1] "Starting armor matrix"
> print(armormatrix <- matrix(startingarmor / 15, 5, shipcells+4))
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
        [,12]    [,13]    [,14]
[1,] 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667
>
> print("Equivalent armor hp:")
[1] "Equivalent armor hp:"
> fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
> print(fullarmorhp)
[1] 100


[1] "Armor damage expected based on full armor hp:"
[1] 5.714286
[1] "Armor damage at middle cells given full shot:"
 [1] 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286
[1] "Probability adjusted armor damage at middle cells:"
 [1] 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286
[1] "Total armor damage incoming at middle cells:"
[1] 5.714286
[1] "New armor matrix:"
     [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14]
[1,] 6.67 6.65 6.63 6.61 6.61 6.61 6.61 6.61 6.61  6.61  6.61  6.63  6.65  6.67
[2,] 6.65 6.61 6.57 6.53 6.51 6.51 6.51 6.51 6.51  6.51  6.53  6.57  6.61  6.65
[3,] 6.65 6.61 6.57 6.53 6.51 6.51 6.51 6.51 6.51  6.51  6.53  6.57  6.61  6.65
[4,] 6.65 6.61 6.57 6.53 6.51 6.51 6.51 6.51 6.51  6.51  6.53  6.57  6.61  6.65
[5,] 6.67 6.65 6.63 6.61 6.61 6.61 6.61 6.61 6.61  6.61  6.61  6.63  6.65  6.67
[1] "Equivalent armor hp:"
[1] 98.7013
[1] "Armor damage expected based on full armor hp:"
[1] 5.76779
[1] "Armor damage at middle cells given full shot:"
 [1] 5.764875 5.780745 5.791107 5.795901 5.797101 5.797101 5.795901 5.791107 5.780745 5.764875
[1] "Probability adjusted armor damage at middle cells:"
 [1] 0.5764875 0.5780745 0.5791107 0.5795901 0.5797101 0.5797101 0.5795901 0.5791107 0.5780745 0.5764875
[1] "Total armor damage incoming at middle cells:"
[1] 5.785946
[1] "New armor matrix:"
     [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14]
[1,] 6.67 6.63 6.59 6.55 6.55 6.55 6.55 6.55 6.55  6.55  6.55  6.59  6.63  6.67
[2,] 6.63 6.55 6.48 6.40 6.36 6.36 6.36 6.36 6.36  6.36  6.40  6.48  6.55  6.63
[3,] 6.63 6.55 6.48 6.40 6.36 6.36 6.36 6.36 6.36  6.36  6.40  6.48  6.55  6.63
[4,] 6.63 6.55 6.48 6.40 6.36 6.36 6.36 6.36 6.36  6.36  6.40  6.48  6.55  6.63
[5,] 6.67 6.63 6.59 6.55 6.55 6.55 6.55 6.55 6.55  6.55  6.55  6.59  6.63  6.67
[1] "Equivalent armor hp:"
[1] 97.38631
[1] "Armor damage expected based on full armor hp:"
[1] 5.822996
[1] "Armor damage at middle cells given full shot:"
 [1] 5.816955 5.849598 5.871049 5.881035 5.883560 5.883560 5.881035 5.871049 5.849598 5.816955
[1] "Probability adjusted armor damage at middle cells:"
 [1] 0.5816955 0.5849598 0.5871049 0.5881035 0.5883560 0.5883560 0.5881035 0.5871049 0.5849598 0.5816955
[1] "Total armor damage incoming at middle cells:"
[1] 5.860439
[1] "New armor matrix:"
     [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14]
[1,] 6.67 6.61 6.55 6.49 6.49 6.49 6.49 6.49 6.49  6.49  6.49  6.55  6.61  6.67
[2,] 6.61 6.49 6.38 6.26 6.20 6.20 6.20 6.20 6.20  6.20  6.26  6.38  6.49  6.61
[3,] 6.61 6.49 6.38 6.26 6.20 6.20 6.20 6.20 6.20  6.20  6.26  6.38  6.49  6.61
[4,] 6.61 6.49 6.38 6.26 6.20 6.20 6.20 6.20 6.20  6.20  6.26  6.38  6.49  6.61
[5,] 6.67 6.61 6.55 6.49 6.49 6.49 6.49 6.49 6.49  6.49  6.49  6.55  6.61  6.67
[1] "Equivalent armor hp:"
[1] 96.05439



Using

damage <- 100
hitstrength <- 100
startingarmor <- 100
shipcells <- 10
probabilities <- c(0.00,0.05,0.10,0.15,0.20,0.20,0.15,0.10,0.05,0.00)
norounds <- 3



Output

[1] "Starting armor matrix"
> print(armormatrix <- matrix(startingarmor / 15, 5, shipcells+4))
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
        [,12]    [,13]    [,14]
[1,] 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667
>
> print("Equivalent armor hp:")
[1] "Equivalent armor hp:"
> fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
> print(fullarmorhp)
[1] 100

[1] "Armor damage expected based on full armor hp:"
[1] 50
[1] "Armor damage at middle cells given full shot:"
 [1] 50 50 50 50 50 50 50 50 50 50
[1] "Probability adjusted armor damage at middle cells:"
 [1]  0.0  2.5  5.0  7.5 10.0 10.0  7.5  5.0  2.5  0.0
[1] "Total armor damage incoming at middle cells:"
[1] 50
[1] "New armor matrix:"
     [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14]
[1,] 6.67 6.67 6.58 6.42 6.17 5.92 5.75 5.75 5.92  6.17  6.42  6.58  6.67  6.67
[2,] 6.67 6.58 6.33 5.92 5.33 4.75 4.42 4.42 4.75  5.33  5.92  6.33  6.58  6.67
[3,] 6.67 6.58 6.33 5.92 5.33 4.75 4.42 4.42 4.75  5.33  5.92  6.33  6.58  6.67
[4,] 6.67 6.58 6.33 5.92 5.33 4.75 4.42 4.42 4.75  5.33  5.92  6.33  6.58  6.67
[5,] 6.67 6.67 6.58 6.42 6.17 5.92 5.75 5.75 5.92  6.17  6.42  6.58  6.67  6.67
[1] "Equivalent armor hp:"
[1] 88.63636
[1] "Armor damage expected based on full armor hp:"
[1] 53.01205
[1] "Armor damage at middle cells given full shot:"
 [1] 51.50215 52.93339 54.75702 56.55042 57.70618 57.70618 56.55042 54.75702 52.93339 51.50215
[1] "Probability adjusted armor damage at middle cells:"
 [1]  0.000000  2.646670  5.475702  8.482564 11.541236 11.541236  8.482564  5.475702  2.646670  0.000000
[1] "Total armor damage incoming at middle cells:"
[1] 56.29234
[1] "New armor matrix:"
     [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14]
[1,] 6.67 6.67 6.50 6.15 5.61 5.07 4.70 4.70 5.07  5.61  6.15  6.50  6.67  6.67
[2,] 6.67 6.50 5.97 5.09 3.84 2.58 1.85 1.85 2.58  3.84  5.09  5.97  6.50  6.67
[3,] 6.67 6.50 5.97 5.09 3.84 2.58 1.85 1.85 2.58  3.84  5.09  5.97  6.50  6.67
[4,] 6.67 6.50 5.97 5.09 3.84 2.58 1.85 1.85 2.58  3.84  5.09  5.97  6.50  6.67
[5,] 6.67 6.67 6.50 6.15 5.61 5.07 4.70 4.70 5.07  5.61  6.15  6.50  6.67  6.67
[1] "Equivalent armor hp:"
[1] 75.84265
[1] "Armor damage expected based on full armor hp:"
[1] 56.86902
[1] "Armor damage at middle cells given full shot:"
 [1] 53.26066 56.62906 61.31532 66.40800 69.97700 69.97700 66.40800 61.31532 56.62906 53.26066
[1] "Probability adjusted armor damage at middle cells:"
 [1]  0.000000  2.831453  6.131532  9.961200 13.995401 13.995401  9.961200  6.131532  2.831453  0.000000
[1] "Total armor damage incoming at middle cells:"
[1] 65.83917
[1] "New armor matrix:"
     [,1] [,2] [,3] [,4] [,5] [,6]  [,7]  [,8] [,9] [,10] [,11] [,12] [,13] [,14]
[1,] 6.67 6.67 6.40 5.85 4.98 4.06  3.43  3.43 4.06  4.98  5.85  6.40  6.67  6.67
[2,] 6.67 6.40 5.58 4.16 2.11 0.01 -1.22 -1.22 0.01  2.11  4.16  5.58  6.40  6.67
[3,] 6.67 6.40 5.58 4.16 2.11 0.01 -1.22 -1.22 0.01  2.11  4.16  5.58  6.40  6.67
[4,] 6.67 6.40 5.58 4.16 2.11 0.01 -1.22 -1.22 0.01  2.11  4.16  5.58  6.40  6.67
[5,] 6.67 6.67 6.40 5.85 4.98 4.06  3.43  3.43 4.06  4.98  5.85  6.40  6.67  6.67
[1] "Equivalent armor hp:"
[1] 60.8792
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 10, 2022, 09:29:25 AM
It seems like damage is indeed much higher in your code than it should be. Question: when you write
Code

            ship.armor_grid.cells[0:5,i:i+5] -= damage * ArmorGrid.WEIGHTS


are you accounting for the fact that when distributing damage, the weights should be 1/15th of those used to pool armor? So that it sums up to 1 and not 15. (because for armor we have already applied the 1/15th when generating the armor matrix, so if we sum it to a total of 15 we get back the starting armor, but we haven't done that for damage so if we use the 1/2 and 1 weights we end up with 15 x too high damage)

I was wondering about that.  I've now fixed that bug and obtained different results.  The numbers I have input before differed from yours only in hit_strength, which I had incorrectly set to 20 rather than your 40.  Inputting these numbers yields a result almost like yours for the first shot, differing by 0.02 for the middle cells.  Correcting the hit strength yields the result that you obtained for the second shot.

I've noticed that your code uses both 14 and 15, but mine uses only 15, and I don't know which is right.  Maybe you could enlighten me.

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
    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]])
   
    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 of each armor cell, which is a square, in
                    pixels
        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, len(self.cells[2,2:-2])) * cell_size
       
    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(
            [np.sum(ArmorGrid.WEIGHTS * self.cells[0:5,i:i+5]) 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 - amount of flux to overload the ship
    flux_dissipation - how much flux the ship can expel without
                       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
                           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:
    base_damage - amount listed under damage in weapon_data.csv
    base_shield_damage - starting amount of damage to be inflicted
                         on the ship shield
    base_armor_damage - starting amount of damage to be inflicted
                        on the ship armor
    strength - strength against armor for armor damage calculation
    flux_hard: whether the flux damage against shields is hard or not
   
    methods:
    - distribute
    - _damage_distribution
    - damage_armor_grid
    """
    def __init__(
            self,
            base_damage: float,
            base_shield_damage: float,
            base_armor_damage: float,
            strength: float,
            flux_hard: bool):
        """
        base_damage - amount listed under damage in weapon_data.csv
        base_shield_damage - starting amount of damage to be inflicted
                             on the ship shield
        base_armor_damage - starting amount of damage to be inflicted
                            on the ship armor
        strength - strength against armor for armor damage calculation
        flux_hard: whether the flux damage against shields is hard or not
        """
        self.base_damage = base_damage
        self.base_shield_damage = base_shield_damage
        self.base_armor_damage = base_armor_damage
        self.strength = strength
        self.probabilities = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a
        row and save the probabilities for later calculation.
        """
        self.probabilities = np.vectorize(distribution)(ship.armor_grid.bounds)
   
    def _damage_distribution(self, ship: object) -> list:
        """
        Return the expected damage to each cell of the shiped armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
        return (self.base_armor_damage
                * self.probabilities
                * ship.armor_grid.damage_factors(self.strength))

    def damage_armor_grid(self, ship):
        """
        Reduce the values of the armor grid cells of the ship
        by the expected value of the damage of the shot across them.
        """
        for i, damage in enumerate(self._damage_distribution(ship)):
            ship.armor_grid.cells[0:5,i:i+5] -= (
                damage
                * ArmorGrid.WEIGHTS
                * ArmorGrid.ARMOR_RATING_PER_CELL_FACTOR
            )
        ship.armor_grid.cells[ship.armor_grid.cells < 0.0] = 0.0
                       
                       
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_armor_grid(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_probability(bound: float): return 0.1 #dummy for test


def main():
    ship_spec = (
        1_000,#hull
        1_000,#flux_capacity
        100#flux_dissipation
    )
    armor_grid_spec = (
        100,#armor_rating
        10,#cell_size
        10#width
    )
    weapons = []
    shot_spec = (
        20,#base_damage,
        10,#base_shield_damage
        40,#base_armor_damage
        20,#strength
        False#flux_hard
    )
   
    ship = Ship(weapons, ArmorGrid(*armor_grid_spec), *ship_spec)
    weapon = Weapon(Shot(*shot_spec), hit_probability)
   
    print(np.round(ship.armor_grid.cells, 2))
    weapon.fire(ship)
    print()
    print(np.round(ship.armor_grid.cells, 2))
main()
[close]
starting armor
[[6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]]
20 strength
[[6.67 6.64 6.62 6.6  6.6  6.6  6.6  6.6  6.6  6.6  6.6  6.62 6.64 6.67]
 [6.64 6.6  6.56 6.51 6.49 6.49 6.49 6.49 6.49 6.49 6.51 6.56 6.6  6.64]
 [6.64 6.6  6.56 6.51 6.49 6.49 6.49 6.49 6.49 6.49 6.51 6.56 6.6  6.64]
 [6.64 6.6  6.56 6.51 6.49 6.49 6.49 6.49 6.49 6.49 6.51 6.56 6.6  6.64]
 [6.67 6.64 6.62 6.6  6.6  6.6  6.6  6.6  6.6  6.6  6.6  6.62 6.64 6.67]]
40 strength
[[6.67 6.63 6.59 6.55 6.55 6.55 6.55 6.55 6.55 6.55 6.55 6.59 6.63 6.67]
 [6.63 6.55 6.48 6.4  6.36 6.36 6.36 6.36 6.36 6.36 6.4  6.48 6.55 6.63]
 [6.63 6.55 6.48 6.4  6.36 6.36 6.36 6.36 6.36 6.36 6.4  6.48 6.55 6.63]
 [6.63 6.55 6.48 6.4  6.36 6.36 6.36 6.36 6.36 6.36 6.4  6.48 6.55 6.63]
 [6.67 6.63 6.59 6.55 6.55 6.55 6.55 6.55 6.55 6.55 6.55 6.59 6.63 6.67]]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 10:00:32 AM
Well, the 14 is only used in the equivalent sum armor HP calculation and that is because the armor matrix is 14 cells wide, so a total of 5*14-4 cells (-4 from excluding corners). Nothing to do with this, 1/15 for central (1/30 for edge) is the correct damage distribution factor.

However, did you notice that in my code I actually had 20 damage and 40 hit strength for the first run, not 40 damage and 20 hit strength? What kind of results do you get with that? What about the other test?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 10, 2022, 10:06:25 AM
Well, the 14 is only used in the equivalent sum armor HP calculation and that is because the armor matrix is 14 cells wide, so a total of 5*14-4 cells (-4 from excluding corners). Nothing to do with this, 1/15 for central (1/30 for edge) is the correct damage distribution factor.

However, did you notice that in my code I actually had 20 damage and 40 hit strength for the first run, not 40 damage and 20 hit strength? What kind of results do you get with that? What about the other test?

Oh, I see now.  Changing my numbers to match yours yields your results!  We've done it!  The expected value calculation now works in Python!

Code
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
    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]])
   
    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 of each armor cell, which is a square, in
                    pixels
        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, len(self.cells[2,2:-2])) * cell_size
       
    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(
            [np.sum(ArmorGrid.WEIGHTS * self.cells[0:5,i:i+5]) 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 - amount of flux to overload the ship
    flux_dissipation - how much flux the ship can expel without
                       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
                           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:
    base_damage - amount listed under damage in weapon_data.csv
    base_shield_damage - starting amount of damage to be inflicted
                         on the ship shield
    base_armor_damage - starting amount of damage to be inflicted
                        on the ship armor
    strength - strength against armor for armor damage calculation
    flux_hard: whether the flux damage against shields is hard or not
   
    methods:
    - distribute
    - _damage_distribution
    - damage_armor_grid
    """
    def __init__(
            self,
            base_damage: float,
            base_shield_damage: float,
            base_armor_damage: float,
            strength: float,
            flux_hard: bool):
        """
        base_damage - amount listed under damage in weapon_data.csv
        base_shield_damage - starting amount of damage to be inflicted
                             on the ship shield
        base_armor_damage - starting amount of damage to be inflicted
                            on the ship armor
        strength - strength against armor for armor damage calculation
        flux_hard: whether the flux damage against shields is hard or not
        """
        self.base_damage = base_damage
        self.base_shield_damage = base_shield_damage
        self.base_armor_damage = base_armor_damage
        self.strength = strength
        self.probabilities = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a
        row and save the probabilities for later calculation.
        """
        self.probabilities = np.vectorize(distribution)(ship.armor_grid.bounds)
   
    def _damage_distribution(self, ship: object) -> list:
        """
        Return the expected damage to each cell of the shiped armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
        return (self.base_armor_damage
                * self.probabilities
                * ship.armor_grid.damage_factors(self.strength))

    def damage_armor_grid(self, ship):
        """
        Reduce the values of the armor grid cells of the ship
        by the expected value of the damage of the shot across them.
        """
        for i, damage in enumerate(self._damage_distribution(ship)):
            ship.armor_grid.cells[0:5,i:i+5] = np.maximum(0,
                ship.armor_grid.cells[0:5,i:i+5]
                - damage
                * ArmorGrid.WEIGHTS
                * ArmorGrid.ARMOR_RATING_PER_CELL_FACTOR)
                       
                       
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_armor_grid(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_probability(bound: float): return 0.1 #dummy for test


def main():
    ship_spec = (
        1_000,#hull
        1_000,#flux_capacity
        100#flux_dissipation
    )
    armor_grid_spec = (
        100,#armor_rating
        10,#cell_size
        10#width
    )
    weapons = []
    shot_spec = (
        20,#base_damage,
        10,#base_shield_damage
        20,#base_armor_damage
        40,#strength
        False#flux_hard
    )
   
    ship = Ship(weapons, ArmorGrid(*armor_grid_spec), *ship_spec)
    weapon = Weapon(Shot(*shot_spec), hit_probability)
   
    print(np.round(ship.armor_grid.cells, 2))
    weapon.fire(ship)
    print()
    print(np.round(ship.armor_grid.cells, 2))
main()
[close]
[[6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]
 [6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67 6.67]]

[[6.67 6.65 6.63 6.61 6.61 6.61 6.61 6.61 6.61 6.61 6.61 6.63 6.65 6.67]
 [6.65 6.61 6.57 6.53 6.51 6.51 6.51 6.51 6.51 6.51 6.53 6.57 6.61 6.65]
 [6.65 6.61 6.57 6.53 6.51 6.51 6.51 6.51 6.51 6.51 6.53 6.57 6.61 6.65]
 [6.65 6.61 6.57 6.53 6.51 6.51 6.51 6.51 6.51 6.51 6.53 6.57 6.61 6.65]
 [6.67 6.65 6.63 6.61 6.61 6.61 6.61 6.61 6.61 6.61 6.61 6.63 6.65 6.67]]
Now I need your results without the pretty rounding so we can compare them to my un-rounded results below and then agree on an accepted result to save in a test to verify that this function works as intended should it later be changed. 
[[6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667]
 [6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667]
 [6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667]
 [6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667]
 [6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667 6.66666667]]

[[6.66666667 6.64761905 6.62857143 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.62857143 6.64761905 6.66666667]
 [6.64761905 6.60952381 6.57142857 6.53333333 6.51428571 6.51428571 6.51428571 6.51428571 6.51428571 6.51428571 6.53333333 6.57142857 6.60952381 6.64761905]
 [6.64761905 6.60952381 6.57142857 6.53333333 6.51428571 6.51428571 6.51428571 6.51428571 6.51428571 6.51428571 6.53333333 6.57142857 6.60952381 6.64761905]
 [6.64761905 6.60952381 6.57142857 6.53333333 6.51428571 6.51428571 6.51428571 6.51428571 6.51428571 6.51428571 6.53333333 6.57142857 6.60952381 6.64761905]
 [6.66666667 6.64761905 6.62857143 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.60952381 6.62857143 6.64761905 6.66666667]]
When comparing our results, we should note and consider any slight numerical differences and also agree to some number of decimals of precision and amount of accuracy if necessary.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 10:43:47 AM
Sure. Same tests. Wouldn't sweat very small decimals as there are only so many ways to get this right so if they are similar to a few decimals it probably is. Before declaring victory though, try the non-uniform distribution and consecutive shots. Edit: oops. The first has the incorrect prob dist. Repost soon. E2: fixed. Complete prints below except I sabotaged the [\\i] to escape from bbcode


> damage <- 20
> hitstrength <- 40
> startingarmor <- 100
> hitstrengthcalc <- function(hitstrength, armor) return(max(0.15,hitstrength/(hitstrength+max(armor,0.05*startingarmor))))
> shipcells <- 10
> probabilities <- c(0.10,0.1,0.10,0.1,0.10,0.10,0.1,0.10,0.1,0.10)
> 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)
> weights
     [,1] [,2] [,3] [,4] [,5]
[1,]  0.0  0.5  0.5  0.5  0.0
[2,]  0.5  1.0  1.0  1.0  0.5
[3,]  0.5  1.0  1.0  1.0  0.5
[4,]  0.5  1.0  1.0  1.0  0.5
[5,]  0.0  0.5  0.5  0.5  0.0
>
> poolarmor <- function(armormatrix, index) {
+   sum <- 0
+   for(i in 1:5)for(j in 1:5) sum <- sum + weights[i,j]*armormatrix[i,index-3+j]
+   return(sum)
+ }
> print("Starting armor matrix")
[1] "Starting armor matrix"
> print(armormatrix <- matrix(startingarmor / 15, 5, shipcells+4))
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
        [,12]    [,13]    [,14]
[1,] 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667
>
> print("Equivalent armor hp:")
[1] "Equivalent armor hp:"
> fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
> print(fullarmorhp)
[1] 100
>
> for(r in 1:norounds){
+
+ armordamagereductions <- vector(shipcells, mode="double")
+ for (x in 1:length(armordamagereductions)) {
+   armordamagereductions
  • <- hitstrengthcalc(hitstrength,poolarmor(armormatrix,x+2))

+ }
+ print("Armor damage expected based on full armor hp:")
+ print(damage*hitstrength/(hitstrength+fullarmorhp))
+
+ armordamagesatmiddlecells <- armordamagereductions*damage
+ print("Armor damage at middle cells given full shot:")
+ print(armordamagesatmiddlecells)
+ print("Probability adjusted armor damage at middle cells:")
+ for(x in 1:length(armordamagesatmiddlecells)) {
+   armordamagesatmiddlecells
  • <- armordamagesatmiddlecells
  • *probabilities

+ }
+ 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] - armordamagesatmiddlecellsi]*weights[j,k]/15
+     }
+   }
+ }
+ print("New armor matrix:")
+ print((armormatrix))
+
+ print("Equivalent armor hp:")
+ fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
+ print(fullarmorhp)
+ }
[1] "Armor damage expected based on full armor hp:"
[1] 5.714286
[1] "Armor damage at middle cells given full shot:"
 [1] 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286 5.714286
[1] "Probability adjusted armor damage at middle cells:"
 [1] 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286 0.5714286
[1] "Total armor damage incoming at middle cells:"
[1] 5.714286
[1] "New armor matrix:"
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524
[2,] 6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333
[3,] 6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333
[4,] 6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333
[5,] 6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524
        [,12]    [,13]    [,14]
[1,] 6.628571 6.647619 6.666667
[2,] 6.571429 6.609524 6.647619
[3,] 6.571429 6.609524 6.647619
[4,] 6.571429 6.609524 6.647619
[5,] 6.628571 6.647619 6.666667
[1] "Equivalent armor hp:"
[1] 98.7013
[1] "Armor damage expected based on full armor hp:"
[1] 5.76779
[1] "Armor damage at middle cells given full shot:"
 [1] 5.764875 5.780745 5.791107 5.795901 5.797101 5.797101 5.795901 5.791107 5.780745 5.764875
[1] "Probability adjusted armor damage at middle cells:"
 [1] 0.5764875 0.5780745 0.5791107 0.5795901 0.5797101 0.5797101 0.5795901 0.5791107 0.5780745 0.5764875
[1] "Total armor damage incoming at middle cells:"
[1] 5.785946
[1] "New armor matrix:"
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.628403 6.590086 6.551735 6.551631 6.551577 6.551557 6.551557 6.551577 6.551631 6.551735
[2,] 6.628403 6.551822 6.475154 6.398435 6.359961 6.359799 6.359728 6.359728 6.359799 6.359961 6.398435
[3,] 6.628403 6.551822 6.475154 6.398435 6.359961 6.359799 6.359728 6.359728 6.359799 6.359961 6.398435
[4,] 6.628403 6.551822 6.475154 6.398435 6.359961 6.359799 6.359728 6.359728 6.359799 6.359961 6.398435
[5,] 6.666667 6.628403 6.590086 6.551735 6.551631 6.551577 6.551557 6.551557 6.551577 6.551631 6.551735
        [,12]    [,13]    [,14]
[1,] 6.590086 6.628403 6.666667
[2,] 6.475154 6.551822 6.628403
[3,] 6.475154 6.551822 6.628403
[4,] 6.475154 6.551822 6.628403
[5,] 6.590086 6.628403 6.666667
[1] "Equivalent armor hp:"
[1] 97.38631
[1] "Armor damage expected based on full armor hp:"
[1] 5.822996
[1] "Armor damage at middle cells given full shot:"
 [1] 5.816955 5.849598 5.871049 5.881035 5.883560 5.883560 5.881035 5.871049 5.849598 5.816955
[1] "Probability adjusted armor damage at middle cells:"
 [1] 0.5816955 0.5849598 0.5871049 0.5881035 0.5883560 0.5883560 0.5881035 0.5871049 0.5849598 0.5816955
[1] "Total armor damage incoming at middle cells:"
[1] 5.860439
[1] "New armor matrix:"
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]    [,7]    [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.609013 6.551198 6.493276 6.492959 6.492791 6.49273 6.49273 6.492791 6.492959 6.493276
[2,] 6.609013 6.493544 6.377807 6.261915 6.203615 6.203117 6.20290 6.20290 6.203117 6.203615 6.261915
[3,] 6.609013 6.493544 6.377807 6.261915 6.203615 6.203117 6.20290 6.20290 6.203117 6.203615 6.261915
[4,] 6.609013 6.493544 6.377807 6.261915 6.203615 6.203117 6.20290 6.20290 6.203117 6.203615 6.261915
[5,] 6.666667 6.609013 6.551198 6.493276 6.492959 6.492791 6.49273 6.49273 6.492791 6.492959 6.493276
        [,12]    [,13]    [,14]
[1,] 6.551198 6.609013 6.666667
[2,] 6.377807 6.493544 6.609013
[3,] 6.377807 6.493544 6.609013
[4,] 6.377807 6.493544 6.609013
[5,] 6.551198 6.609013 6.666667
[1] "Equivalent armor hp:"
[1] 96.05439
>


> damage <- 100
> hitstrength <- 100
> startingarmor <- 100
> hitstrengthcalc <- function(hitstrength, armor) return(max(0.15,hitstrength/(hitstrength+max(armor,0.05*startingarmor))))
> shipcells <- 10
> probabilities <- c(0.00,0.05,0.10,0.15,0.20,0.20,0.15,0.10,0.05,0.00)
> 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)
> weights
     [,1] [,2] [,3] [,4] [,5]
[1,]  0.0  0.5  0.5  0.5  0.0
[2,]  0.5  1.0  1.0  1.0  0.5
[3,]  0.5  1.0  1.0  1.0  0.5
[4,]  0.5  1.0  1.0  1.0  0.5
[5,]  0.0  0.5  0.5  0.5  0.0
>
> poolarmor <- function(armormatrix, index) {
+   sum <- 0
+   for(i in 1:5)for(j in 1:5) sum <- sum + weights[i,j]*armormatrix[i,index-3+j]
+   return(sum)
+ }
> print("Starting armor matrix")
[1] "Starting armor matrix"
> print(armormatrix <- matrix(startingarmor / 15, 5, shipcells+4))
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667 6.666667
        [,12]    [,13]    [,14]
[1,] 6.666667 6.666667 6.666667
[2,] 6.666667 6.666667 6.666667
[3,] 6.666667 6.666667 6.666667
[4,] 6.666667 6.666667 6.666667
[5,] 6.666667 6.666667 6.666667
>
> print("Equivalent armor hp:")
[1] "Equivalent armor hp:"
> fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
> print(fullarmorhp)
[1] 100
>
> for(r in 1:norounds){
+
+ armordamagereductions <- vector(shipcells, mode="double")
+ for (x in 1:length(armordamagereductions)) {
+   armordamagereductions
  • <- hitstrengthcalc(hitstrength,poolarmor(armormatrix,x+2))

+ }
+ print("Armor damage expected based on full armor hp:")
+ print(damage*hitstrength/(hitstrength+fullarmorhp))
+
+ armordamagesatmiddlecells <- armordamagereductions*damage
+ print("Armor damage at middle cells given full shot:")
+ print(armordamagesatmiddlecells)
+ print("Probability adjusted armor damage at middle cells:")
+ for(x in 1:length(armordamagesatmiddlecells)) {
+   armordamagesatmiddlecells
  • <- armordamagesatmiddlecells
  • *probabilities

+ }
+ 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] - armordamagesatmiddlecellsi]*weights[j,k]/15
+     }
+   }
+ }
+ print("New armor matrix:")
+ print((armormatrix))
+
+ print("Equivalent armor hp:")
+ fullarmorhp <- (sum(armormatrix[2:4,])+sum(armormatrix[1,2:13])+sum(armormatrix[5,2:13]))/(5*14-4)*15
+ print(fullarmorhp)
+ }
[1] "Armor damage expected based on full armor hp:"
[1] 50
[1] "Armor damage at middle cells given full shot:"
 [1] 50 50 50 50 50 50 50 50 50 50
[1] "Probability adjusted armor damage at middle cells:"
 [1]  0.0  2.5  5.0  7.5 10.0 10.0  7.5  5.0  2.5  0.0
[1] "Total armor damage incoming at middle cells:"
[1] 50
[1] "New armor matrix:"
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.666667 6.583333 6.416667 6.166667 5.916667 5.750000 5.750000 5.916667 6.166667 6.416667
[2,] 6.666667 6.583333 6.333333 5.916667 5.333333 4.750000 4.416667 4.416667 4.750000 5.333333 5.916667
[3,] 6.666667 6.583333 6.333333 5.916667 5.333333 4.750000 4.416667 4.416667 4.750000 5.333333 5.916667
[4,] 6.666667 6.583333 6.333333 5.916667 5.333333 4.750000 4.416667 4.416667 4.750000 5.333333 5.916667
[5,] 6.666667 6.666667 6.583333 6.416667 6.166667 5.916667 5.750000 5.750000 5.916667 6.166667 6.416667
        [,12]    [,13]    [,14]
[1,] 6.583333 6.666667 6.666667
[2,] 6.333333 6.583333 6.666667
[3,] 6.333333 6.583333 6.666667
[4,] 6.333333 6.583333 6.666667
[5,] 6.583333 6.666667 6.666667
[1] "Equivalent armor hp:"
[1] 88.63636
[1] "Armor damage expected based on full armor hp:"
[1] 53.01205
[1] "Armor damage at middle cells given full shot:"
 [1] 51.50215 52.93339 54.75702 56.55042 57.70618 57.70618 56.55042 54.75702 52.93339 51.50215
[1] "Probability adjusted armor damage at middle cells:"
 [1]  0.000000  2.646670  5.475702  8.482564 11.541236 11.541236  8.482564  5.475702  2.646670  0.000000
[1] "Total armor damage incoming at middle cells:"
[1] 56.29234
[1] "New armor matrix:"
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.666667 6.495111 6.145921 5.613169 5.066683 4.697832 4.697832 5.066683 5.613169 6.145921
[2,] 6.666667 6.495111 5.974365 5.092423 3.841630 2.577103 1.847055 1.847055 2.577103 3.841630 5.092423
[3,] 6.666667 6.495111 5.974365 5.092423 3.841630 2.577103 1.847055 1.847055 2.577103 3.841630 5.092423
[4,] 6.666667 6.495111 5.974365 5.092423 3.841630 2.577103 1.847055 1.847055 2.577103 3.841630 5.092423
[5,] 6.666667 6.666667 6.495111 6.145921 5.613169 5.066683 4.697832 4.697832 5.066683 5.613169 6.145921
        [,12]    [,13]    [,14]
[1,] 6.495111 6.666667 6.666667
[2,] 5.974365 6.495111 6.666667
[3,] 5.974365 6.495111 6.666667
[4,] 5.974365 6.495111 6.666667
[5,] 6.495111 6.666667 6.666667
[1] "Equivalent armor hp:"
[1] 75.84265
[1] "Armor damage expected based on full armor hp:"
[1] 56.86902
[1] "Armor damage at middle cells given full shot:"
 [1] 53.26066 56.62906 61.31532 66.40800 69.97700 69.97700 66.40800 61.31532 56.62906 53.26066
[1] "Probability adjusted armor damage at middle cells:"
 [1]  0.000000  2.831453  6.131532  9.961200 13.995401 13.995401  9.961200  6.131532  2.831453  0.000000
[1] "Total armor damage incoming at middle cells:"
[1] 65.83917
[1] "New armor matrix:"
         [,1]     [,2]     [,3]     [,4]     [,5]       [,6]      [,7]      [,8]       [,9]    [,10]
[1,] 6.666667 6.666667 6.400729 5.847155 4.982363 4.06374553  3.432765  3.432765 4.06374553 4.982363
[2,] 6.666667 6.400729 5.581217 4.162851 2.113504 0.01033242 -1.219502 -1.219502 0.01033242 2.113504
[3,] 6.666667 6.400729 5.581217 4.162851 2.113504 0.01033242 -1.219502 -1.219502 0.01033242 2.113504
[4,] 6.666667 6.400729 5.581217 4.162851 2.113504 0.01033242 -1.219502 -1.219502 0.01033242 2.113504
[5,] 6.666667 6.666667 6.400729 5.847155 4.982363 4.06374553  3.432765  3.432765 4.06374553 4.982363
        [,11]    [,12]    [,13]    [,14]
[1,] 5.847155 6.400729 6.666667 6.666667
[2,] 4.162851 5.581217 6.400729 6.666667
[3,] 4.162851 5.581217 6.400729 6.666667
[4,] 4.162851 5.581217 6.400729 6.666667
[5,] 5.847155 6.400729 6.666667 6.666667
[1] "Equivalent armor hp:"
[1] 60.8792
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 11:19:46 AM
Also: note that computation of the exact value for the first hit can be done easily by hand. So for example we know that for the middle cells with 20 damage and 40 hit strength and the uniform distribution, it must be

100/15-40/140*20*(3/10*1/15+2/10*1/30)=228/35 approx 6.5142857142857142857
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 10, 2022, 02:20:31 PM
I've almost gotten your result.  I started with these numbers in a file I named data.json
{
    "Shot.damage_armor_grid":{
        "test_config":{
            "decimal_places":6
        },
        "shot_spec":{
            "base_damage":10.0,
            "base_armor_damage":20.0,
            "base_shield_damage":5.0,
            "strength":40.0,
            "flux_hard":false
        },
        "armor_grid_spec":{
            "armor_rating":100.0,
            "cell_size":10.0,
            "width":10
        },
        "armor_grid_cell_values":{
            "initial":[
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667]
            ],
            "after_shot":[
                [6.666667,6.647619,6.628571,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.583333,6.666667,6.666667],
                [6.647619,6.609524,6.571429,6.533333,6.514286,6.514286,6.514286,6.514286,6.514286,6.514286,6.533333,6.333333,6.583333,6.666667],
                [6.647619,6.609524,6.571429,6.533333,6.514286,6.514286,6.514286,6.514286,6.514286,6.514286,6.533333,6.333333,6.583333,6.666667],
                [6.647619,6.609524,6.571429,6.533333,6.514286,6.514286,6.514286,6.514286,6.514286,6.514286,6.533333,6.333333,6.583333,6.666667],
                [6.666667,6.647619,6.628571,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.583333,6.666667,6.666667]
            ]
        }
    }
}
and got this result
TEST FAILED
Location: test_Shot.test_damage_armor_grid
Problem: armor grid after shot does not equal expected one

grid_after_shot_expected
[[6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.583333 6.666667 6.666667]
 [6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333 6.333333 6.583333 6.666667]
 [6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333 6.333333 6.583333 6.666667]
 [6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333 6.333333 6.583333 6.666667]
 [6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.583333 6.666667 6.666667]]

grid_after_shot
[[6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.628571 6.647619 6.666667]
 [6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333 6.571429 6.609524 6.647619]
 [6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333 6.571429 6.609524 6.647619]
 [6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333 6.571429 6.609524 6.647619]
 [6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.628571 6.647619 6.666667]]

grid_after_shot - grid_after_shot_expected
[[ 0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.045238 -0.019048  0.      ]
 [ 0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.238096  0.026191 -0.019048]
 [ 0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.238096  0.026191 -0.019048]
 [ 0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.238096  0.026191 -0.019048]
 [ 0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.        0.045238 -0.019048  0.      ]]
with this code
Code
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
    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]])
   
    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 of each armor cell, which is a square, in
                    pixels
        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, len(self.cells[2,2:-2])) * cell_size
       
    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(
            [np.sum(ArmorGrid.WEIGHTS * self.cells[0:5,i:i+5]) 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 - amount of flux to overload the ship
    flux_dissipation - how much flux the ship can expel without
                       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
                           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:
    base_damage - amount listed under damage in weapon_data.csv
    base_shield_damage - starting amount of damage to be inflicted
                         on the ship shield
    base_armor_damage - starting amount of damage to be inflicted
                        on the ship armor
    strength - strength against armor for armor damage calculation
    flux_hard: whether the flux damage against shields is hard or not
   
    methods:
    - distribute
    - _damage_distribution
    - damage_armor_grid
    """
    def __init__(
            self,
            base_damage: float,
            base_shield_damage: float,
            base_armor_damage: float,
            strength: float,
            flux_hard: bool):
        """
        base_damage - amount listed under damage in weapon_data.csv
        base_shield_damage - starting amount of damage to be inflicted
                             on the ship shield
        base_armor_damage - starting amount of damage to be inflicted
                            on the ship armor
        strength - strength against armor for armor damage calculation
        flux_hard: whether the flux damage against shields is hard or not
        """
        self.base_damage = base_damage
        self.base_shield_damage = base_shield_damage
        self.base_armor_damage = base_armor_damage
        self.strength = strength
        self.probabilities = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a
        row and save the probabilities for later calculation.
        """
        self.probabilities = np.vectorize(distribution)(ship.armor_grid.bounds)
   
    def _damage_distribution(self, ship: object) -> list:
        """
        Return the expected damage to each cell of the shiped armor
        row times the probability to hit it.
       
        damage - damage against armor after reduction by armor
        """
        return (self.base_armor_damage
                * self.probabilities
                * ship.armor_grid.damage_factors(self.strength))

    def damage_armor_grid(self, ship):
        """
        Reduce the values of the armor grid cells of the ship
        by the expected value of the damage of the shot across them.
        """
        for i, damage in enumerate(self._damage_distribution(ship)):
            ship.armor_grid.cells[0:5,i:i+5] = np.maximum(0,
                ship.armor_grid.cells[0:5,i:i+5]
                - damage
                * ArmorGrid.WEIGHTS
                * ArmorGrid.ARMOR_RATING_PER_CELL_FACTOR)
                       
                       
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_armor_grid(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.py
Code
import combat_entities
import numpy as np
import json
       
   
def test_Shot():
    def test_damage_armor_grid():
        def hit_probability(bound: float): return 0.1 #dummy for test
       
        with open('data.json') as f:
            data = json.load(f)["Shot.damage_armor_grid"]
       
        decimal_places = data["test_config"]["decimal_places"]
        ship_spec = (
            1_000,#hull
            1_000,#flux_capacity
            100#flux_dissipation
        )
        armor_grid_spec = (
            data["armor_grid_spec"]["armor_rating"],
            data["armor_grid_spec"]["cell_size"],
            data["armor_grid_spec"]["width"]
        )
        weapons = []
        shot_spec = (
            data["shot_spec"]["base_damage"],
            data["shot_spec"]["base_shield_damage"],
            data["shot_spec"]["base_armor_damage"],
            data["shot_spec"]["strength"],
            data["shot_spec"]["flux_hard"]
        )
       
        ship = combat_entities.Ship(weapons,
                                    combat_entities.ArmorGrid(*armor_grid_spec),
                                    *ship_spec)
        weapon = combat_entities.Weapon(combat_entities.Shot(*shot_spec),
                                        hit_probability)
       
        initial_grid_expected = np.array(
            data["armor_grid_cell_values"]["initial"])
        initial_grid = np.round(ship.armor_grid.cells, decimal_places)
        if not (initial_grid == initial_grid_expected).all():
            print("TEST FAILED")
            print("Location: test_Shot.test_damage_armor_grid")
            print("Problem: initial armor grid does not equal expected one.")
            try:
                print("initial_grid - initial_grid_expected")
                print(initial_grid - initial_grid_expected)
            except:
                print("Could not subtract grids")
       
        weapon.fire(ship)
       
        grid_after_shot_expected = np.array(
            data["armor_grid_cell_values"]["after_shot"])
        grid_after_shot = np.round(ship.armor_grid.cells, decimal_places)
        if not (grid_after_shot == grid_after_shot_expected).all():
            print("TEST FAILED")
            print("Location: test_Shot.test_damage_armor_grid")
            print("Problem: armor grid after shot does not equal expected one")
            try:
                print()
                print("grid_after_shot_expected")
                print(grid_after_shot_expected)
                print()
                print("grid_after_shot")
                print(grid_after_shot)
                print()
                print("grid_after_shot - grid_after_shot_expected")
                print(grid_after_shot - grid_after_shot_expected)
            except:
                print("Could not subtract grids")
       
    test_damage_armor_grid()
[close]
main.py
Code
import test

def main():
    test.test_Shot()
main()
[close]
[close]

I have noticed that my result is symmetrical whereas yours is less damaged on the right.  I wonder if we have implemented or applied our probability functions differently.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 02:54:37 PM
Well, if yours is symmetrical and mine is not, then yours would be correct because the damage must be symmetrical, to the extent that you should be able to only calculate it halfway and mirror the rest, since we always target the center and have a symmetrical probability distribution. I'm pretty bad with indexing which has been the bane of my code time and again so it is entirely possibly due to that again. Don't worry about it. I see absolutely no way how you could go from a few integers to exactly the right numbers there other than a bug in mine. I currently can't get to computer so can't debug.

But do try the successive shots and nonuniform dist as those ensure the adr is computed correctly. If the result is the same other than at extreme right where it must be a problem on my end then your code is exactly correct here.

Edit: though looking at it, are you sure it isn't such as a copypaste error? In my first print in first post on this page the result looks symmetrical unlike the one you copied. But it is split across lines due to R and no rounding.

Quote

"New armor matrix:"
         [,1]     [,2]     [,3]     [,4]     [,5]     [,6]     [,7]     [,8]     [,9]    [,10]    [,11]
[1,] 6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524
[2,] 6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333
[3,] 6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333
[4,] 6.647619 6.609524 6.571429 6.533333 6.514286 6.514286 6.514286 6.514286 6.514286 6.514286 6.533333
[5,] 6.666667 6.647619 6.628571 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524 6.609524
        [,12]    [,13]    [,14]
[1,] 6.628571 6.647619 6.666667
[2,] 6.571429 6.609524 6.647619
[3,] 6.571429 6.609524 6.647619
[4,] 6.571429 6.609524 6.647619
[5,] 6.628571 6.647619 6.666667

Columns 12, 13 and 14 are on a new line due to not fitting in the R print horizontally.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 10, 2022, 03:33:54 PM
You're right: I had incorrectly copied the expected numbers.  Deleting and re-copying the expected numbers fixed the problem.

My next questions are:
1. The constructor arguments of each combat entity should be the relevant data from the game files.  What numbers shall we need and  formulae would yield them from the ones we have?
2. How many successive shots should we test?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 04:25:24 PM
Well, I think this roadmap is still good, though I added the extra step of simulations to test the model vs itself and the error correction part.

R   Py
[ ] [?] Import data for ships and weapons (ships: hullhp, default flux dissipation, flux capacity, armor, width in px, no. armor cells, shield width in px, shield upkeep)
     (weapons: damage, chargeup, chargedown, burst size, burst delay, ammo capacity, ammo regen, ammo reload size, type (beam or not))
[v] [v] Create probability distribution of hits over ship
[v] [ ] Create sequence describing hits from weapon at timepoint (in whole seconds) during the simulation
[v] [v] Armor damage function to be used during combat simulation (the most complex part of the thing - use intrinsic_parity's)
[v] [ ] Testing function to plot a large number of ships using simulated random hits and plot model prediction vs the average
[v] [ ] Main combat loop (
   flow: 1. check whether using shields to block -> do not block and dissipate soft and then hard flux if blocking would overload you,
   else block and dissipate soft flux only
   2. damage shields by damage to shields * probability to hit shields if blocking with shields
   3. damage armor using the armor damage function
   4. damage hull
   5. repeat until dead, record time to kill)
[v] [ ] Graph combat loop to make sure everything is working as needed
[v] [ ] Build and test error corrected armor damage function using expected ADR
[v] [ ] Put it all together, loop over weapons using a particular algorithm or just brute force, compare times to kill



I think the next logical step would be to run the same 3 shot trials I did above to see results are still the same. Then we know it is accurate for a few shors. Then after that, to test an arbitrary number of shots, you should pit the model vs itself - plot the armor and hull damage to, say, 100 ships using random hits on the armor vs. the model's prediction to yield a residual plot like I did above. If the error is consistently a few % of hull hp at point of kill when killing a Dominator with a 400 damage energy weapon (can check the error's direction and magnitude vs intrinsic_parity's and my plots) then that means you have successfully replicated the uncorrected model. This is also an important function to build for later for quantifying error and if you want to try to implement error correction.

You should get roughly around 60 shots to kill with a relatively wide distribution with those parameters and also find a slight model error. It's hard to be exact about how many shots it should take because unfortunately exact probability distributions used for hits weren't posted at the time, and that affects it, but seems to be about that in our previous models. Getting something in the 50 to 70 range with a relatively wide hit distribution would be a favorable sign but deviation from that doesn't necessarily mean error (as long as numbers aren't absurd) as like said seems we didn't note the dist and also could be hidden bugs in previous code. What is important is that the sim results are reasonable and the model fits them as expected with a small error. Can extend the "dumb" elementary operations model to provide comparison data with an exactly specified probability, too.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 10, 2022, 07:19:34 PM
It passes the three-shot test!  Sounds like I need to code some sort of ship combat logic.  Also, how should I arrange for the extended test?  Perhaps I could have the armor grid states from your test?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 10, 2022, 10:19:19 PM
Well, it wouldn't really make sense to use mine, since this is supposed to be a test of the model's own workings, in two ways: 1) that it predicts random results using its own functions correctly, and therefore, if those functions are true to the game, results from the game correctly and 2) it produces the exactly correct result (without model error from the naive model's lack of handling of expected adr values) as a mean of random ships. Note that unlike the naive model's prediction this is expected to be the actual mean from the game as when using random shots we do not hit the expected value from expected value problem. Then later the final step is to show the error correction corrects the prediction to the mean.

For this test you need to be able to calculate hull damage, and should write a function to damage armor with 1 shot at ship cell x. This will be needed later for the star matrices, too, so I suggest a separate function. Then repeat that with random shots until death for 1 ship, repeat 100 times, graph results and compute mean/median (your preference) of hull damage. Compare model prediction to this. The alternative hackish way is to repeatedly pass your current functions prob dists like 0 0 1 0 0 0 ... with a random index set to 1 and 0 otherwise, but since you need a performant version of this for later anyway, might as well do it now. You can check your work by plotting the new vs the old with the hackish probability method with a number of random ships and seeing results are similar.

So the test should run like
- create undamaged ship
- damage it using random shots until it dies, record hull hp at each step
- repeat 100 times, calculate mean/median hull hp (these are expected to converge at the end so shouldn't be a big deal which, the distribution starts skewed when hull hp is only going down for a part of ships but by the end there should be equal deviation both ways from the mean)
- get predictions from model for hull hp vs the same ship with the same weapon with the same prob dist as the random shots
- compare results by graphing or tabling as is your preference

I would not add shields to this test yet, since that should essentially just mean adding a cycle of skipping damage to armor to the model if successful, and should not demonstrate any additional error because shield calculation by the model is exact as there is no consecutive exp values problem when the operations are linear, but is a source of potential errors. Later, though, you can use this test with shields included to demonstrate total error in the model when shields and realistic weapons are in.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 11, 2022, 07:55:56 PM
I have been following the above instructions, decided to compare the average number of simulated firings to destroy the ship to the calculated number before trying the statistics you mentioned, and so suspect my result proves the calculation to accurately represent the simulation that I have stopped working until you review this result and my code.
Number of firings to destory ship
Calculated: 272
Average Simulated: 273.67
Code
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 _ship_spec(data):
    return (1_000,#hull
            1_000,#flux_capacity
            100)#flux_dissipation)


def _hit_probability(bound: float): return 0.1#dummy for test


def _armor_grid_spec(data):
    return (data["armor_grid_spec"]["armor_rating"],
            data["armor_grid_spec"]["cell_size"],
            data["armor_grid_spec"]["width"])

def _weapons(data):
    return []


def _shot_spec(data):
    return (data["shot_spec"]["base_damage"],
            data["shot_spec"]["base_shield_damage"],
            data["shot_spec"]["base_armor_damage"],
            data["shot_spec"]["strength"],
            data["shot_spec"]["flux_hard"])


def test_armor_grid_constructor():
    armor_grid = combat_entities.ArmorGrid(*_armor_grid_spec(_data()))
    assert (np.round(armor_grid.cells, _decimal_places(_data()))
            == np.array(_data()["armor_grid_cell_values"]["initial"])).all(), (
            "Initial armor grid does not equal expected one.")

   
def test_damage_armor_grid():
    armor_grid = combat_entities.ArmorGrid(*_armor_grid_spec(_data()))
    ship = combat_entities.Ship(_weapons(_data()), armor_grid,
                                *_ship_spec(_data()))
    shot = combat_entities.Shot(*_shot_spec(_data()))
    shot.distribute(ship, _hit_probability)
    decimal_places = _decimal_places(_data())
    for i, expected_armor_grid in enumerate(
            _data()["armor_grid_cell_values"]["after_shots"]):
        for i, damage in enumerate(shot._damage_distribution(armor_grid)):
            shot.damage_armor_grid(ship.armor_grid, damage, i)
        ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)
        assert (np.round(ship.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) -> object:
    index = np.random.randint(0, len(ship.armor_grid.bounds))
    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.base_armor_damage * damage_factor
    shot.damage_armor_grid(ship.armor_grid, damage, index)
    middle_cells = ship.armor_grid.cells[2][2:-2]
    hull_damage = np.sum(middle_cells[middle_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():
    shot = combat_entities.Shot(*_shot_spec(_data()))
    armor_grid_spec = _armor_grid_spec(_data())
    weapons = _weapons(_data())
    ship_spec = _ship_spec(_data())
    calculated_ship = combat_entities.Ship(
        weapons,
        combat_entities.ArmorGrid(*armor_grid_spec),
        *ship_spec)
    shot.distribute(calculated_ship, _hit_probability)
    trials = 100
   
    calculated_firings = 0
    while calculated_ship.hull > 0:
        shot.damage_ship(calculated_ship)
        calculated_firings += 1

    simulated_firings = []
    for trial in range(trials):
        simulated_ship = combat_entities.Ship(
            weapons,
            combat_entities.ArmorGrid(*armor_grid_spec),
            *ship_spec)
        firing = 0
        while simulated_ship.hull > 0:
            _simulate_hit(shot, simulated_ship)
            firing += 1
        simulated_firings.append(firing)

    print()
    print("Number of firings to destory ship")
    print("Calculated:", calculated_firings)
    print("Average Simulated:", np.average(simulated_firings))
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 11, 2022, 09:33:28 PM
Well, it does seem right, and the direction of error is right and magnitude seems ok, but I'm unable to comment on whether it should take that many shots without weapon data since I was unable to find it in the code. A couple of things:

Your simulated hit function uses
Code
    index = np.random.randint(0, len(ship.armor_grid.bounds))

I think it would be better to pass the index to the function, and have a separate function that generates indices according to a custom probability distribution. For example, knowing that probabilities sum to 1, you could pass it the hit probability distribution you want to model, calculate the cumulative distribution (ie. for index k, sum of indices up to and inclusive of k) to get upper bounds for boxes, and check which box a random uniform variable from 0 to 1 falls in. This way you can simulate with any probability distribution that you want, which is relevant because we would expect that to affect error in the model, and you can also use the simulated hit function for the hypothetical matrices later where you must pass a predetermined index to it.

The error is eliminated whenever the minimum armor rule or the minimum damage rule is in effect, so that is why it's important to test a weapon that is not strong enough to instantly destroy armor, but also not weak enough to be under the minimum damage rule most of the time to see the greatest possible error. So I'd recommend testing against a Dominator (1500 armor, 14000 hull, 12 cells) with a 400 damage energy weapon, because we have two independent simulations existing of what the shot count should be (roughly, though, because the expected prob dists weren't published except as being wide, but we can also rig the "dumb" version to calculate it if doubts arise).

Looking at firings to kill is a good idea. I'd also be interested in difference in hull - this is because we might combine a variety of weapons so we would want each weapon to be reasonably accurate about hull separately as the weapons will deliver the kill in combination. Print both results?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 12, 2022, 04:45:27 AM
Well, it does seem right, and the direction of error is right and magnitude seems ok, but I'm unable to comment on whether it should take that many shots without weapon data since I was unable to find it in the code. A couple of things:

It's the same weapon data as usual.
{
    "Shot.damage_armor_grid":{
        "test_config":{
            "decimal_places":6
        },
        "shot_spec":{
            "base_damage":10.0,
            "base_armor_damage":20.0,
            "base_shield_damage":5.0,
            "strength":40.0,
            "flux_hard":false
        },
        "armor_grid_spec":{
            "armor_rating":100.0,
            "cell_size":10.0,
            "width":10
        },
        "armor_grid_cell_values":{
            "initial":[
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667],
                [6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667,6.666667]
            ],
            "after_shots":[
      [[6.666667,6.647619,6.628571,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.628571,6.647619,6.666667],
                 [6.647619,6.609524,6.571429,6.533333,6.514286,6.514286,6.514286,6.514286,6.514286,6.514286,6.533333,6.571429,6.609524,6.647619],
                 [6.647619,6.609524,6.571429,6.533333,6.514286,6.514286,6.514286,6.514286,6.514286,6.514286,6.533333,6.571429,6.609524,6.647619],
                 [6.647619,6.609524,6.571429,6.533333,6.514286,6.514286,6.514286,6.514286,6.514286,6.514286,6.533333,6.571429,6.609524,6.647619],
                 [6.666667,6.647619,6.628571,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.609524,6.628571,6.647619,6.666667]],
      [[6.666667,6.628403,6.590086,6.551735,6.551631,6.551577,6.551557,6.551557,6.551577,6.551631,6.551735,6.590086,6.628403,6.666667],
       [6.628403,6.551822,6.475154,6.398435,6.359961,6.359799,6.359728,6.359728,6.359799,6.359961,6.398435,6.475154,6.551822,6.628403],
       [6.628403,6.551822,6.475154,6.398435,6.359961,6.359799,6.359728,6.359728,6.359799,6.359961,6.398435,6.475154,6.551822,6.628403],
       [6.628403,6.551822,6.475154,6.398435,6.359961,6.359799,6.359728,6.359728,6.359799,6.359961,6.398435,6.475154,6.551822,6.628403],
       [6.666667,6.628403,6.590086,6.551735,6.551631,6.551577,6.551557,6.551557,6.551577,6.551631,6.551735,6.590086,6.628403,6.666667]],
      [[6.666667,6.609013,6.551198,6.493276,6.492959,6.492791,6.49273,6.49273,6.492791,6.492959,6.493276,6.551198,6.609013,6.666667],
       [6.609013,6.493544,6.377807,6.261915,6.203615,6.203117,6.20290,6.20290,6.203117,6.203615,6.261915,6.377807,6.493544,6.609013],
       [6.609013,6.493544,6.377807,6.261915,6.203615,6.203117,6.20290,6.20290,6.203117,6.203615,6.261915,6.377807,6.493544,6.609013],
       [6.609013,6.493544,6.377807,6.261915,6.203615,6.203117,6.20290,6.20290,6.203117,6.203615,6.261915,6.377807,6.493544,6.609013],
       [6.666667,6.609013,6.551198,6.493276,6.492959,6.492791,6.49273,6.49273,6.492791,6.492959,6.493276,6.551198,6.609013,6.666667]]
            ]
        }
    }
}


Quote
Your simulated hit function uses
Code
    index = np.random.randint(0, len(ship.armor_grid.bounds))

I think it would be better to pass the index to the function, and have a separate function that generates indices according to a custom probability distribution. For example, knowing that probabilities sum to 1, you could pass it the hit probability distribution you want to model, calculate the cumulative distribution (ie. for index k, sum of indices up to and inclusive of k) to get upper bounds for boxes, and check which box a random uniform variable from 0 to 1 falls in. This way you can simulate with any probability distribution that you want, which is relevant because we would expect that to affect error in the model, and you can also use the simulated hit function for the hypothetical matrices later where you must pass a predetermined index to it.

Sure.
Code
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.base_armor_damage * damage_factor
    shot.damage_armor_grid(ship.armor_grid, damage, index)
    middle_cells = ship.armor_grid.cells[2][2:-2]
    hull_damage = np.sum(middle_cells[middle_cells<0])
    ship.hull = max(0, ship.hull + hull_damage)
    ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)

Quote
The error is eliminated whenever the minimum armor rule or the minimum damage rule is in effect, so that is why it's important to test a weapon that is not strong enough to instantly destroy armor, but also not weak enough to be under the minimum damage rule most of the time to see the greatest possible error. So I'd recommend testing against a Dominator (1500 armor, 14000 hull, 12 cells) with a 400 damage energy weapon, because we have two independent simulations existing of what the shot count should be (roughly, though, because the expected prob dists weren't published except as being wide, but we can also rig the "dumb" version to calculate it if doubts arise).

Sounds like a good idea.  Would you mind creating a .json for those numbers and the resulting armor grids as shown above?

Quote
Looking at firings to kill is a good idea. I'd also be interested in difference in hull - this is because we might combine a variety of weapons so we would want each weapon to be reasonably accurate about hull separately as the weapons will deliver the kill in combination. Print both results?

Would you please elaborate about what you mean by "difference in hull" because I thought the test would continue until the ship were destroyed?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 12, 2022, 06:48:42 AM
Ok let's try this:

Code
       "shot_spec":{
            "base_damage":400.0,
            "base_armor_damage":400.0,
            "base_shield_damage":400.0,
            "strength":400.0,
            "flux_hard":true
        },

Code
        "armor_grid_spec":{
            "armor_rating":1500.0,
            "cell_size":10.0,
            "width":12
        },

I do not know what cell_size means or how you configure hull hp unfortunately. The correct armor grid should be 16 cells wide and have 100 armor hp in each cell

Quote
Would you please elaborate about what you mean by "difference in hull" because I thought the test would continue until the ship were destroyed?


Alright let me fire up MSPaint
(https://i.ibb.co/YLbyWBz/image.png) (https://ibb.co/z4N75Vy)

Of course with 1 weapon R_hull (mean hull simulated ships have left when model dies) and R_shot (how many more shots it takes to kill the mean simulated ship than the model) are function of each other. But we might prefer knowing the magnitude of R_hull, because what if the weapon runs out of ammo a few shots before kill, and we have to continue with a pea shooter? Then how will model error affect us? (Of course you could still answer "a function of how many big gun shots would be left but I think the hull residual is more intuitive. The shot residual is better for description of single weapon error tho)

Edit to add: the curved line is supposed to represent mean of ships despite not looking like it. Sorry about the level of artwork here, I was a little pressed for time.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 12, 2022, 07:32:59 AM
Ok let's try this:

Code
       "shot_spec":{
            "base_damage":400.0,
            "base_armor_damage":400.0,
            "base_shield_damage":400.0,
            "strength":400.0,
            "flux_hard":true
        },

Code
        "armor_grid_spec":{
            "armor_rating":1500.0,
            "cell_size":10.0,
            "width":12
        },

I do not know what cell_size means or how you configure hull hp unfortunately. The correct armor grid should be 16 cells wide and have 100 armor hp in each cell

Oh, I had left cell_size and hull hardcoded.  I can make those configurable.  I still need those armor grid states I mentioned.

Quote
Alright let me fire up MSPaint
(https://i.ibb.co/YLbyWBz/image.png) (https://ibb.co/z4N75Vy)

Of course with 1 weapon these two are a function of each other. But we might prefer knowing the magnitude of R_hull, because what if the weapon runs out of ammo a few shots before kill, and we have to continue with a pea shooter? Then how will model error affect us?

Using the same .json as before because I need the new armor grids to make the test pass,
Number of firings to destroy ship
Calculated: 272
Average Simulated: 273

Average hull value
Calculated: 565
Simulated: 566
Code
Code
def test_calculation_vs_simulation():
    shot = combat_entities.Shot(*_shot_spec(_data()))
    armor_grid_spec = _armor_grid_spec(_data())
    weapons = _weapons(_data())
    ship_spec = _ship_spec(_data())
    calculated_ship = combat_entities.Ship(
        weapons,
        combat_entities.ArmorGrid(*armor_grid_spec),
        *ship_spec)
    simulated_ship = combat_entities.Ship(
        weapons,
        combat_entities.ArmorGrid(*armor_grid_spec),
        *ship_spec)
    shot.distribute(calculated_ship, _hit_probability)
    trials = 100
   
    calculated_firings = 0
    calculated_hull = 0
    while calculated_ship.hull > 0:
        shot.damage_ship(calculated_ship)
        calculated_hull += calculated_ship.hull
        calculated_firings += 1
    calculated_hull /= calculated_firings

    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 = combat_entities.ArmorGrid(*armor_grid_spec)
        simulated_ship.hull = 1000
        simulated_firings += firing
    simulated_firings /= trials
    simulated_hull /= trials

    print()
    print("Number of firings to destroy ship")
    print("Calculated:", calculated_firings)
    print("Average Simulated:", round(simulated_firings))
    print()
    print("Average hull value")
    print("Calculated:", round(calculated_hull))
    print("Simulated:", round(simulated_hull))
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 12, 2022, 07:59:46 AM
Could you elaborate - what do you mean by armor grid states? A sample shot series from the dumb model?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 12, 2022, 08:07:30 AM
Could you elaborate - what do you mean by armor grid states? A sample shot series from the dumb model?

What do you mean by the dumb model?  I mean the series of shots we did last time but for these numbers that you have chosen now.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral 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.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral 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
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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:
(https://i.imgur.com/us6FYJ7.png) (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: (https://i.ibb.co/47GFCX6/image.png) (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".
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral 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]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral 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]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral 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]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral 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?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity 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.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector 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?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 15, 2022, 11:40:44 AM
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.

If you wanna optimize the Conquest, you're gonna need asymmetrical distributions to account for the limited turret traverse because it can't fire both broadsides at once.

Quote
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.

That's what I did, and it was about as fast if not a little slower.

Quote
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.

I have no clue either.  Maybe NumPy doesn't like that shape.  ???

Quote
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 wish you could understand the NumPy code because you would have seen that the pooling weights are already a 5x5 matrix and that it is multiplied by a 5x5 slice of the armor grid, which is also a matrix; it even has a second 5x5 damage distribution matrix that has already baked the 1/15 factor in.  We might need other libraries to speed this code up further.

Quote
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))

I have tried it now, and it is about ten times worse than the weighted approach because selecting those odd numbers requires python coding.  NumPy is so much faster than Python that brute force but idiomatic approaches can beat smart but custom ones.

Quote
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?

How would I do that?

Quote
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

Two kinds of optimization exist in a tight NumPy loop: reducing the work that NumPy must do and reducing the amount of Python code involved.

Quote
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.

I don't quite understand.

Quote
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).

Tried it now, and it is 50% slower than the usual.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Thaago on December 15, 2022, 11:48:13 AM
When you have code where you are happy/confident that it is outputting correct values, I can do an optimization pass as I have some experience in that with python. Send me a DM please as I'm only checking in here every once in a while!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 15, 2022, 11:48:54 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.

Yeah, I would agree, though while we're working directly with the bottom of the code, we might as well see about squashing it down.  Here's hoping that we find other ways to save time.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 15, 2022, 11:49:25 AM
When you have code where you are happy/confident that it is outputting correct values, I can do an optimization pass as I have some experience in that with python. Send me a DM please as I'm only checking in here every once in a while!

Wooooooo!  ;D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 15, 2022, 01:56:09 PM
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?

I have ideas about ways of running numerical optimization on loadouts using some sort of combat model, but I think we are still quite far from having a simulation that is actually useful for that purpose.

In terms of performance, I think the issues are a little overstated. On my laptop (2019 Intel MacBook Pro, 2.3 GHz I9), my MATLAB code (which is not super optimized) takes about ~7 seconds to run the armor damage calculations 1000000 times (without any parallelization). My laptop has relatively unimpressive clock speed and 8 cores, so I would expect 3-6x reduction in time with parallelization based on personal experience. I would expect comparable performance (or better with good optimization) in python.

Based on my experience, most combat simulations take a few hundred shots for a kill, so that 1000000 armor damage calculations probably corresponds to 1-2 thousand combat simulations. I think that speed should be sufficient to run optimization code in a reasonable amount of time (several minutes). Obviously any improvements would be great though. Brute force searching all possible combinations of weapons/loadouts for a ship is still probably out of the question though just due to the combinatorics of how many possible ways there are to create a loadout. Obviously this is all very ‘back of the napkin’ approximations.

Here is my assessment of where we are and what we still need to do:

Current state (feel free to add anything I am missing):
in python:
- armor damage calculations (given hit strength and armor grid)
- expected armor damage (given shot distribution, hit strength and armor grid)
- some shot distribution calculations (given range, armor grid dimensions)
in R:
- more complex weapon mechanics (beams, charge up/charge down, burst weapons)
- some rudimentary AI for shield management
- a basic simulation

The remaining non-translated R code is mostly part of the broader simulation rather than the damage calculations. We still need to build out all the simulation code in python that calls the damage calculations.

In order to do numerical optimization, what we need is a function which takes all of the parameters to be optimized (loadout, hopefully including all weapons, hullmods, caps/vents), as well as the simulation parameters (ships, skills, opponent loadout etc.) and runs the simulation to return the value of the metric that we are trying to improve for that specific case. We will also likely need to write some constraint functions, but we can worry about that later.

What I think we need to do:
- translate remaining R code for weapon mechanics and stuff to python
    - this might require to some reformulation depending on how the simulation is implemented in python
- create a simulation structure in python
    - need to handle flux dynamics, including soft/hard flux from both firing weapons and shield damage
    - need to figure out time steps to handle all the different types of weapons (beams, burst weapons etc.)
    - IMO we need to handle variable range as well (will talk about this later)
- create a programatic system for getting appropriate weapons and ship data
    - I know Liral was trying to do this for weapons stats from the CSV file already
    - ship stats should be similar (parsing a CSV file), unless we try to handle actual ship geometry for the armor grid, and need to pull that data (which is not in a file format I am familiar with)
- choose a metric to optimize and create a function that utilizes the simulation to calculate that metric
- wrap this in the optimization code (probably from a generic optimization package, I can handle this)

Handling range is something I’ve been think about. Obviously, the big issue is that the shot distribution depends on range, and we don’t want to be recalculating that constantly, but I have some ideas when we get there. We would also need some rudimentary AI to manage range for each ship.

Also, I think we are definitely getting to the point where a GitHub would be very helpful for collaboration. I think the method of copy/pasting code from the forum will get very inefficient very fast. I am happy to write some code.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 15, 2022, 05:06:18 PM
I have ideas about ways of running numerical optimization on loadouts using some sort of combat model, but I think we are still quite far from having a simulation that is actually useful for that purpose.

In terms of performance, I think the issues are a little overstated. On my laptop (2019 Intel MacBook Pro, 2.3 GHz I9), my MATLAB code (which is not super optimized) takes about ~7 seconds to run the armor damage calculations 1000000 times (without any parallelization). My laptop has relatively unimpressive clock speed and 8 cores, so I would expect 3-6x reduction in time with parallelization based on personal experience. I would expect comparable performance (or better with good optimization) in python.

Glad to read more confirmation that a potato can handle it.

Quote
Based on my experience, most combat simulations take a few hundred shots for a kill, so that 1000000 armor damage calculations probably corresponds to 1-2 thousand combat simulations. I think that speed should be sufficient to run optimization code in a reasonable amount of time (several minutes). Obviously any improvements would be great though. Brute force searching all possible combinations of weapons/loadouts for a ship is still probably out of the question though just due to the combinatorics of how many possible ways there are to create a loadout. Obviously this is all very ‘back of the napkin’ approximations.

Your calculations match the ones I did earlier.

Quote
Here is my assessment of where we are and what we still need to do:

Current state (feel free to add anything I am missing):
in python:
- armor damage calculations (given hit strength and armor grid)
- expected armor damage (given shot distribution, hit strength and armor grid)
- some shot distribution calculations (given range, armor grid dimensions)
in R:
- more complex weapon mechanics (beams, charge up/charge down, burst weapons)
- some rudimentary AI for shield management
- a basic simulation

The remaining non-translated R code is mostly part of the broader simulation rather than the damage calculations. We still need to build out all the simulation code in python that calls the damage calculations.

In order to do numerical optimization, what we need is a function which takes all of the parameters to be optimized (loadout, hopefully including all weapons, hullmods, caps/vents), as well as the simulation parameters (ships, skills, opponent loadout etc.) and runs the simulation to return the value of the metric that we are trying to improve for that specific case. We will also likely need to write some constraint functions, but we can worry about that later.

What I think we need to do:
- translate remaining R code for weapon mechanics and stuff to python
    - this might require to some reformulation depending on how the simulation is implemented in python
- create a simulation structure in python
    - need to handle flux dynamics, including soft/hard flux from both firing weapons and shield damage
    - need to figure out time steps to handle all the different types of weapons (beams, burst weapons etc.)
    - IMO we need to handle variable range as well (will talk about this later)
- create a programatic system for getting appropriate weapons and ship data
    - I know Liral was trying to do this for weapons stats from the CSV file already
    - ship stats should be similar (parsing a CSV file), unless we try to handle actual ship geometry for the armor grid, and need to pull that data (which is not in a file format I am familiar with)

The database is good-enough for general-purpose use but could be made more convenient.  The database can read CSVs and .ship files from vanilla and some mods, returning nested dictionaries for each source, ship_data.csv row, weapon_data.csv row, and .ship file.  I could make it transform the data types of the 'bottom' dictionary elements from strings into directly-usable floats, decimals, and integers, and I could even write accessor methods to allow searching by weapon_id and ship_id directly.

Quote
- choose a metric to optimize and create a function that utilizes the simulation to calculate that metric
- wrap this in the optimization code (probably from a generic optimization package, I can handle this)

Handling range is something I’ve been think about. Obviously, the big issue is that the shot distribution depends on range, and we don’t want to be recalculating that constantly, but I have some ideas when we get there. We would also need some rudimentary AI to manage range for each ship.

Let's just let the user set one or several engagement ranges before we start talking AI.

Quote
Also, I think we are definitely getting to the point where a GitHub would be very helpful for collaboration. I think the method of copy/pasting code from the forum will get very inefficient very fast. I am happy to write some code.

CapnHector, what do you think?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 15, 2022, 07:58:23 PM
I think definitely start a github now. The discussion is excellent. Also, my ability to contribute as a person who is profiled 100% for theory rather than programming is getting quite limited. Feel free to proceed from here as you see fit.

I do have an idea about asymmetrical weapon arcs. Essentially it's this: if the target is at the edge of the weapon arc then this results in a shifted uniform distribution for the firing angle. So a shifted version of the convolved normal dlst. Then when you have multiple weapons with different limited arcs the ship should calculate the optimal position for dealing damage to the target and assume that position. This is a matter of maximizing damage*area under curve of probability distribution from ship edge to ship edge for all weapons. And that should be just a function of damages, shifts and coordinates. Using calculus we already have an integral of the PDF so auc is just F(highercoord)-F(lowercoord), but we probably don't even need calculus because in our reality the distribution is discretized and can be formulated using sums. I'll get to working out the details when I have a chance.

I won't develop the matrix theory further since you did find it to be slower in the most basic function, although let me know if you think it would still be useful.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 15, 2022, 08:22:22 PM
I think definitely start a github now. The discussion is excellent.

I agree.

Quote
Also, my ability to contribute as a person who is profiled 100% for theory rather than programming is getting quite limited. Feel free to proceed from here as you see fit.

I feel sad because learning how to code could open many doors for you in academia, work, and hobbies, and I hope you learn soon because you could do it and would enjoy it.  I would even teach you, answer your questions, etc.! 

Quote
I do have an idea about asymmetrical weapon arcs. Essentially it's this: if the target is at the edge of the weapon arc then this results in a shifted uniform distribution for the firing angle. So a shifted version of the convolved normal dlst. Then when you have multiple weapons with different limited arcs the ship should calculate the optimal position for dealing damage to the target and assume that position. This is a matter of maximizing damage*area under curve of probability distribution from ship edge to ship edge for all weapons. And that should be just a function of damages, shifts and coordinates. Using calculus we already have an integral of the PDF so auc is just F(highercoord)-F(lowercoord), but we probably don't even need calculus because in our reality the distribution is discretized and can be formulated using sums. I'll get to working out the details when I have a chance.

Sweeeeeet!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 15, 2022, 08:53:27 PM
Oh don't worry, I definitely have been inspired to learn, when I have an opening in my schedule. This project is currently in my hobby slot and it's been fun and educational. I've even found a professional idea about theoretical statistics I'd like to spend some time researching at some point, but we'll see.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 15, 2022, 09:23:19 PM
Dealing with weapon arcs also requires dealing with the rotational state of the ships, which once again might require some rudimentary AI.

Also, when I say rudimentary AI, I mean some basic conditional rules, not like a whole complicated attempt to imitate the game. It could be as simple as 'fly forward if xyz flux conditions met, else fly backward' for range. I guess there's nothing wrong with also being able to handle fixed range, but IMO it's a pretty integral part of combat.

Rotations seem harder to deal with. I guess you could again choose a specific rotational state, but it seems quite complicated to make that user friendly.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 15, 2022, 10:11:02 PM
Here is what I think we're solving illustrated professionally in MSPaint
(https://i.ibb.co/FJ9xQHJ/image.png) (https://ibb.co/L6MkTN6)
(of course in reality there are an arbitrary number of weapons and a minimum and maximum angle for each based on how you rotate the ship towards enemy ie. select target angle, of which either the minimum or maximum is used based on whether the target is higher or lower in angle)

Now here's the thing that the computation does become recursive if we want to find the optimum angle for the whole combat, because then we'd want to know which weapons have the highest dps vs hull, armor and shields on average vs. the target, but since shields regenerate we'd need to know how long the combat lasts in the first place to know how much we should weight shield dps. There are at least three solutions:
1) guess the value (say, based on how long the average combat lasts vs that ship, or alternatively a naive non-matrix based calculation of how long it might last)
2) allow the ship to rotate during combat, so that it rotates to maximize armor damage when enemy flux is max, and only consider dps vs. the component you are targeting currently.
3) use fixed values based on ship stats to determine how to weight damage vs. shields, damage vs. armor and damage vs. hull.
Of course there is also the "correct" solution that you pick some reasonable starting point, run the combat, then improve your optimum target angle estimates based on that, then re-run the combat until you find the optimal angle. But I don't think we want to do that. I think basically we'd want the rotation version where the optimum angle changes once shields are down, since that is the realistic thing to do anyway.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 16, 2022, 06:04:43 AM
For now, why not just let the user specify an angle for each ship in a separate file, as we do for range?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 16, 2022, 07:13:53 AM
Well, for now I say ignore this whole issue since we don't even support weapon arcs of fire. First should get the utility put together as is, with all weapons firable directly at the target. This will work fine for testing the balance of weapons, though not of ships. That should take some time so good time to figure this out. The priority now should be integrating the code, putting together a complete fight against a ship and creating functions to print out graphs of it and optimizing in my opinion. I'm just thinking about this since I can't really help with that.

As is, I don't think adding a rotation parameter is very useful right now since it does nothing unless the whole structure with ship rotation and turret arcs are implemented. It would be a complete pain for the user to specify a rotation for each ship to be tested manually, not to mention quite unrealistic to test all possible rotations of a number of ships, so I don't think that's a good option, rather implement a coherent way for the program to handle the whole thing or skip entirely for the time being (like for the Conquests I only included the  missiles and one broadside).

This should eventually amount to a change to the hit distribution based on weapon arcs and ship rotation so nothing that can't be added later or that would interfere with the damage calculations. Well, other than it breaks horizontal symmetry, so the code must not assume that. Vertical symmetry on the other hand would only be broken by a deeper than 1 row amor grid.

In the meantime, users can test rotation much more intuitively by specifying which weapon slots are available. Want to test Conquest broadside? 2x LM, 2x medium missile, 2x large gun,  2x medium. Want to test frontal firepower at medium range? 2x large missile, 2x medium energy. Etc. Not saying this is optimal, just that nothing need be added for now until the complete package is figured out.  But it's already publishable without it for weapon and weapon combinations testing and that can even be a later upgrade.

By the way, here are some more initial thoughts on the turret rotation issue. Don't know when have a chance to really sit down with it but I keep  thinking about it occasionally. Say that angle 0 is the angle exactly to the right of the ship's facing. Then we can give each turret a maximum and minimum angle. Let's say that a turret's maximum angle and minimum angle in these coordinates are m and n. Then it should be the case that the maximum mean point for the turret's hit distribution is m-spread/2 (see mspaint above) and minimum is n+spread/2. Call these points a and b respectively.

Now to target the enemy ship, let's say the enemy ship is at angle t in these coordinates. We can freely rotate so can freely choose t. Then for each gun the midpoint of the probability distribution is a, when t>a, t, when a>t>b, b< t>b.

Then to calculate AUC we should calculate  F(x2)-F(x1) where x2 and x1 are the enemy ship's edge angles and F(x) is the CDF of probability (determined fully by the mean point, spread and sd). Now given that it's also the case that x1=t-width/2 and x2=t+width/2 then this should actually lead to a differentiable expression for AUC as a function of t, especially when we use the differentiable approximation x/(1+e^-x) for max(x,0).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 16, 2022, 08:39:34 AM
FWIW, rotation state also changes the locations of armor cells, and changes which cells are visible, and the AI rotates the ship with this in mind as well. It's definitely pretty complicated. I think range is much simpler to handle.

If we are picking a fixed angle, we should just check which weapons can fire forward in the selected orientation and use those, rather than assuming all weapons can fire.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 16, 2022, 09:50:56 AM
I'm still not necessarily sold on adding returning fire or enemy ship rotation. I guess it's a matter of what the goals of the program are. If the goal is a quick, rough search of potentially overpowered weapons or problematic layouts then it does not necessarily serve our interests to add a lot of complexity and assumptions. Not only does it increase processing time but also adds more sources of potential errors in code or in our assumptions or model.

On the other hand, if the goal is a fully realistic test then it absolutely must be added. The question is, is that a realistic goal for the program? Will it ever be fully realistic? Will people rather not use the sim especially if the program takes very long? Will our recreation of combat be credible?

In my opinion the first use case is more justifiable since a player could never do such a wide search as this model can in the sim and that is where this is irreplaceable.

Ultimately if the final code is developed together it's a matter of time and discussion to see what features come about ofc.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 16, 2022, 11:59:55 AM
Our code can already test many individual weapons, or small weapon groups, against a brick wall of armor, and that is already impressive.  With just a little work, it could even give the brick wall a shield.  A whole mod to do this manually already exists (Practice Targets) and we could largely automate and widen that process.  I call that progress made in just a month.  Now for the rest?  If we have the time and inclination, sure, we kinda could do at least some of it, but as CapnHector points out, the bigger the goal, the more work it would take and more misleading a small mistake could be.

As for user demand, I have some unrelated modder-labor-saving statistical code that I have wanted to publish for a while but did not because it would have been too small a mod on its own.  What if we bundled them into a nice little mod called StatSector? :D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 16, 2022, 12:53:55 PM
IMO, return fire is a necessity if you want to consider shields/shield damage, and also if you want to evaluate loadouts as a whole. Raw damage output is not really that useful without the context of the rest of the loadout, ship and enemy. For instance, the Mjolnir and Gauss look great in your earlier results, because you don't consider flux at all. Both are very flux expensive and somewhat inefficient. IMO, those weapons are frequently unusable on smaller ships because of the flux cost, so I would say they are not top choices for the average ship.

To test a weapon, what I would want to do is something like:
Choose some set of ships and loadouts that you consider to be balanced, and test them against some 'reasonable' enemies to determine a baseline, then modify the loadouts to incorporate your new weapon, and see how the results compare. Just shooting a target dummy doesn't tell you enough to a balance a weapon IMO.

In a perfect world, I would want a mod which basically takes some input files to determine the ships and loadouts, and then runs the in-game combat simulator with no graphics and returns combat results (damage values, times, etc.). I view what we are doing as building towards something like that, but maybe it would be easier to just do that IDK. I don't have enough experience with JAVA to consider attempting that without investing way more time than I have.

Also, return fire is very trivial to add? It's all the same code as your own ships, so any bugs would also appear in your own ship. And as I mentioned earlier, the computational cost is really not that significant. I think your original code was just really slow/poorly optimized giving you a false impression of how expensive the computations are :P.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 16, 2022, 07:58:31 PM
As for user demand, I have some unrelated modder-labor-saving statistical code that I have wanted to publish for a while but did not because it would have been too small a mod on its own.  What if we bundled them into a nice little mod called StatSector? :D

Sounds good, you have my go-ahead!

Also, return fire is very trivial to add? It's all the same code as your own ships, so any bugs would also appear in your own ship. And as I mentioned earlier, the computational cost is really not that significant. I think your original code was just really slow/poorly optimized giving you a false impression of how expensive the computations are :P.

Oh no doubt. It was total spaghetti written in R. This new code will be at least 150x faster. The question is just what happens when you have a large number of weapons and layouts. For example if you have 20 different weapons and should choose 5 out of those, and we are also using realistic ships so slots are not interchangeable, then that will already result in 20^5 so  3 200 000 combats, about 30 times as many as I did, since you can choose the same weapon more than once. And that is a vast understatement of how many weapons are available if you are trying to balance mods. Or how many weapon slots ships have, for that matter.

Won't realistic return fire at least double computations, since it's running damage, shot spread etc. for our ship also, with a possibility of losing? Or would it just be limited to shields? In the latter case, isn't it just a modifier to flux? And open to a lot of criticism about unrealistic assumptions. Also, when explaining the results you, must then say these are the results given this kind of return fire. Whether that's good depends on the context of whether people would prefer to know base weapon effectiveness or a more realistic combat model.

On the other hand adding flux to our ship seems quite reasonable and easy to do. Just keep track of it and delay fire until it is low enough. Unfortunately this necessitates re-writing the firing sequence code to be able to delay shots. If return fire exists then in fact the firing sequence must be re-computed at each timepoint, since we do not know in advance what the return fire will be, given it's another system with feedback loops when both ships have flux and guns. It's not just an extend sequence by adding 0s operation either, because ammo will still regenerate when not firing. With no return fire we could pre-compute the sequence as we do now, but there is a question of how the ship should prioritize firing weapons. Should it save flux in some circumstances?

There is also another very simple solution to consider flux without any of this: compute flux to kill in addition to time to kill. This should be quite easy since we are already keeping track of how many shots/beam ticks have been fired. Then in addition to ranking weapons by time to kill, also compute a flux to kill rank. This is non-trivial and interesting information because usually very hard hitting weapons consume a lot of flux/sec, but also kill faster, so it's not necessarily true they take more flux to kill.

I would advocate for the last option for the time being.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 16, 2022, 08:20:08 PM
Now we have a dictionary of all ship and weapon specs, comprising the relevant .csv and .ship values, each typed appropriately albeit with some tweaking still needed on instantiation depending on weapon type, indexed as database[source]["weapons" or "ships"][weapon_id or ship_id].

database.py
Code
"""
Database of all ship and weapon specs, divided into one
section for each mod alongside one for vanilla.
"""
import os
import csv
import json
from sys import platform
import decimal
from decimal import Decimal


class Database:
    """
    Provides the data needed to instantiate the Ship and Weapon classes.

    Loads every weapon_data.csv, ship_data.csv, and .ship file from the
    vanilla folder and mods folder and returns the necessary data as a
    flat dictionary.

    methods:
    - weapon_data
    - ship_data
    """
    _IDS_OF_MODS_CAUSING_ERRORS = (
        'armaa',
        'gundam_uc',
        'SCY'
    )

    _WEAPON_DATA_CSV_COLUMN_DATA_TYPES = {
        "name" : str,
        "id" : str,
        "tier" : int,
        "rarity" : Decimal,
        "base value" : int,
        "range" : Decimal,
        "damage/second" : Decimal,
        "damage/shot" : Decimal,
        "emp" : Decimal,
        "impact" : Decimal,
        "turn rate" : Decimal,
        "OPs" : int,
        "ammo" : int,
        "ammo/sec" : Decimal,
        "reload size" : int,
        "type" : str,
        "energy/shot" : Decimal,
        "energy/second" : Decimal,
        "chargeup" : Decimal,
        "chargedown" : Decimal,
        "burst size" : Decimal,
        "burst delay" : Decimal,
        "min spread" : Decimal,
        "max spread" : Decimal,
        "spread/shot" : Decimal,
        "spread decay/sec" : Decimal,
        "beam speed" : Decimal,
        "proj speed" : Decimal,
        "launch speed" : Decimal,
        "flight time" : Decimal,
        "proj hitpoints" : int,
        "autofireAccBonus" : Decimal,
        "extraArcForAI" : Decimal,
        "hints" : str,
        "tags" : str,
        "groupTag" : str,
        "tech/manufacturer" : str,
        "for weapon tooltip>>" : str,
        "primaryRoleStr" : str,
        "speedStr" : str,
        "trackingStr" : str,
        "turnRateStr" : str,
        "accuracyStr" : str,
        "customPrimary" : str,
        "customPrimaryHL" : str,
        "customAncillary" : str,
        "customAncillaryHL" : str,
        "noDPSInTooltip" : bool,
        "number" : Decimal
    }

    _SHIP_DATA_CSV_COLUMN_DATA_TYPES = {
        "name" : str,
        "id" : str,
        "designation" : str,
        "tech/manufacturer" : str,
        "system id" : str,
        "fleet pts" : int,
        "hitpoints" : int,
        "armor rating" : int,
        "max flux" : int,
        "8/6/5/4%" : Decimal,
        "flux dissipation" : int,
        "ordnance points" : int,
        "fighter bays" : int,
        "max speed" : Decimal,
        "acceleration" : Decimal,
        "deceleration" : Decimal,
        "max turn rate" : Decimal,
        "turn acceleration" : Decimal,
        "mass" : int,
        "shield type" : str,
        "defense id" : str,
        "shield arc" : Decimal,
        "shield upkeep" : Decimal,
        "shield efficiency" : Decimal,
        "phase cost" : Decimal,
        "phase upkeep" : Decimal,
        "min crew" : int,
        "max crew" : int,
        "cargo" : int,
        "fuel" : int,
        "fuel/ly" : Decimal,
        "range" : Decimal,
        "max burn" : int,
        "base value" : int,
        "cr %/day" : Decimal,
        "CR to deploy" : int,
        "peak CR sec" : Decimal,
        "CR loss/sec" : Decimal,
        "supplies/rec" : int,
        "supplies/mo" : Decimal,
        "c/s" : Decimal,
        "c/f" : Decimal,
        "f/s" : Decimal,
        "f/f" : Decimal,
        "crew/s" : Decimal,
        "crew/f" : Decimal,
        "hints" : str,
        "tags" : str,
        "rarity" : Decimal,
        "breakProb" : Decimal,
        "minPieces" : int,
        "maxPieces" : int,
        "travel drive" : str,
        "number" : Decimal
    }
    def __init__(self):
        decimal.places = 6
        if platform == "darwin":
            self._vanilla_path = "Contents/Resources/Java"
        #elif platform == "linux":
            #self._vanilla_path = linux path
        #elif platform == "win32":
            #self._vanilla_path = windows path
        self._sources = self._load_sources()

    def __getitem__(self, source_id: str) -> dict:
        """
        Returns a dictionary of the source with this id.

        source_id - vanilla for the vanilla files and the relevant
                    mod_id for mod files
        """
        return self._sources[source_id]

    def _is_mod_causing_errors() -> bool:
        if 'mod_info.json' not in os.listdir(os.getcwd()):
            return True
        with open('mod_info.json') as f:
            lines = f.readlines()
        for line in lines:
            if '"id"' in line:
                for ID in Database._IDS_OF_MODS_CAUSING_ERRORS:
                    if ID in line: return True
        return False

    def _subdirectory_paths(path: str) -> tuple:
        """
        Return a list of the relative paths of the
        directories within this one.

        path - a path
        """
        return tuple(f.path for f in os.scandir(path) if f.is_dir())

    def _csv_dictionary(file_name: str) -> dict:
        """
        Return a .csv as a flat dictionary keyed by row, with elements
        typed by column.
        """
        types = (Database._WEAPON_DATA_CSV_COLUMN_DATA_TYPES
                 if file_name == "weapon_data.csv" else
                 Database._SHIP_DATA_CSV_COLUMN_DATA_TYPES)
        with open(file_name) as f:
            rows = tuple(row for row in csv.reader(f))
        column_names = rows[0]
        dictionary = {}
        for row in rows[1:]:
            ID = row[1]
            if ID == "": continue
            dictionary[ID] = {}
            for i, value in enumerate(row):
                if value == "": continue
                column_name = column_names[i]
                data_type = types[column_name]
                if data_type == str: value = data_type(value)
                dictionary[ID][column_name] = value
        return dictionary

    def _source_dictionary(path):
        """
        Return the data of the ships and weapons of this
        source, organized by origin.
        """
        source = {}
        directories = [path.split("/")[-1] for path in
                          Database._subdirectory_paths(os.getcwd())]
        if 'data' in directories:
            os.chdir('data')
            directories = [path.split("/")[-1] for path in
                           Database._subdirectory_paths(os.getcwd())]
        else: return source
        if 'weapons' in directories:
            os.chdir('weapons')
            if 'weapon_data.csv' in os.listdir(os.getcwd()):
                source['weapons'] = Database._csv_dictionary('weapon_data.csv')
            os.chdir('..')
        if 'hulls' in directories:
            os.chdir('hulls')
            if 'ship_data.csv' in os.listdir(os.getcwd()):
                source['ships'] = Database._csv_dictionary('ship_data.csv')
                for ship_id in source["ships"]:
                    if ship_id + '.ship' in os.listdir(os.getcwd()):
                        with open(ship_id + '.ship') as f:
                            source["ships"][ship_id].update(json.load(f))
            os.chdir('..')
        return source

    def _load_sources(self):
        """
        Consolidate the ship and weapon data of vanilla (and every mod
        selected by the user) into one dictionary for simulation and
        calculation use.

        The structure of this dictionary is

        {
            "ships":{
                "shipId":{
                    "attributeId":value
                }
            },
            "weapons" : {
                "weaponId":{
                    "attributeId":value
                }
            }
        }

        where every shipId, weaponId, and attributeId is taken from the
        game or mod files.
        """
        all_data = dict()
        os.chdir("..")
        os.chdir("..")
        for path in Database._subdirectory_paths(os.getcwd()):
            os.chdir(path)
            if Database._is_mod_causing_errors():
                print(("WARNING: mod at", path, "not loaded because its"
                      ".csv or .ship files cause errors."))
                os.chdir('..')
                continue
            all_data[path] = Database._source_dictionary(path)
            os.chdir("..")
        os.chdir("..")
        os.chdir("..")
        os.chdir(self._vanilla_path)
        all_data['vanilla'] = Database._source_dictionary(self._vanilla_path)
        return all_data
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 16, 2022, 10:27:40 PM
A factor of 2 in compute time should not be a big deal IMO.

Also, you can definitely get a good idea of balance with a small set of tests. The only time you need to do really exhaustive searches IMO, is if you are trying to find the 'optimal' builds completely numerically (without any prior knowledge or intuition). If you start with some idea of some builds with existing (balanced) weapons, that are good, then you really only need to compare your new stuff to the existing stuff to see if it is an outlier. Basically, a smart testing methodology should preclude the need for exhaustive searches.

Also, it really shouldn't be an issue to have multiple ships firing at once.

If the simulation time step is the same as the game so that shots always happen exactly at simulation time steps, then for normal weapons (no burst/charge up/charge down) it's just some straightforward modular arithmetic to check if a weapon is firing or not at any time step, given the time of the first shot. If you know in advance when weapons with start/stop firing, you can do all the checks in advance, but there is also a good argument for supporting some dynamic decision making for when to fire or not, since that is how the game actually works.

For burst/charge up/charge down weapons, what I would do is precompute the firing profile of a single burst with reference to the start of the burst, then do modular arithmetic to determine when the burst starts and execute the pre computed burst sequence at those times.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 17, 2022, 07:36:48 AM
Well, we'll cross that bridge when we get there. Turning to the math, I'm not done here yet.

math
(https://i.ibb.co/PZfvQkD/image.png) (https://ibb.co/DDSHpdr)
(https://i.ibb.co/3kzcrWW/image.png) (https://ibb.co/b7JXWLL)
(https://i.ibb.co/gSPKMLQ/image.png) (https://ibb.co/9bV59kB)
[close]

But I did happen to derive the equations for simulating the probability distribution of hits on the enemy ship when rotating our ship.

Here are some graphs. AUC=probability of hits on ship times damage (damage is 1 where not specified), angle=angle of enemy ship to our target (0 degrees = exactly to the right, 90 degrees = exactly in front), offset = offset of guns' facing from 0 degrees, positive for one gun and negative for the other (ie offset 30=one is facing +30 and one -30 degrees). Range 1000px. Enemy ship width 220 px (Dominator).
Test 1. A simple plot of the 1 gun solution. Vertical lines are a and b. Note that the distribution's top turns flat in the area where the gun can follow the target due to tracking.
(https://i.ibb.co/GxdnBvj/image.png) (https://ibb.co/r7QFTGJ)

Test 2. 2 guns with a 20 degree turn range with variable offset, gun 1 has offset +offset and gun 2 has offset -offset.
(https://i.ibb.co/yn8gXmJ/image.png) (https://ibb.co/ccN21Kd)
The correct choice, when the dists do not overlap, is to select one of the guns to use, and rotate so that the target is in the area where the gun can track it.

Test 3. 1 gun fixed to the right of the ship dealing 3 damage with a 30 degree spread but unable to rotate, 2 guns with variable offset one dealing 2 damage and one dealing 1 damage. The correct solution is to overlap guns 2 and 1 when possible, when not you can use either gun 1 or gun 2 (roughly).
(https://i.ibb.co/kcCykdn/image.png) (https://ibb.co/KyvGR1Z)

If I can't get the equations to come together (I will, eventually, for sure, but) then there is always the option of just brute forcing it by testing the 360 possible 1 degree facings for which produces the most DPS. This is a relatively small computation compared to the main damage calculation, it's comparable to creating the hit dists or firing sequences.

Code:
Spoiler
Code
library(ggplot2)
library(reshape2)

n <- 50/1000
w <- 110/1000
G <- function(y) return(y*pnorm(y)+dnorm(y))
Fz <- function(z, u) return(n/2/u*(G(z/n+u/n)-G(z/n-u/n)))
h <- function(x,a,b) return(min(a,max(x,b)))

u1 <- 10 * pi/180
u2 <- 2.5 * pi/180
u3 <- 0

AUC <- function(t,a,b,u) return(Fz(t+w-h(t,a,b),u)-Fz(t-w-h(t,a,b),u))
                             
#test 1
df <- data.frame()
offset1 <- 60 * pi/180
a1 <- offset1+u1
b1 <- offset1-u1
for (angle in 1:360){
  t <- angle*pi/180
  df <- rbind(df,c((AUC(t,a1,b1,a1-b1)),t))
}
colnames(df) <- c("auc","angle")
df$angle<-df$angle*180/pi
df
ggplot(df,aes(y=auc,x=angle))+
  geom_line()+
  geom_vline(xintercept=a1*180/pi)+
  geom_vline(xintercept=b1*180/pi)


#test 2 - 2 similar guns dealing equal damage
df <- data.frame()
for (offset in seq(1,180,2)){
  roffset <- offset*pi/180
  a1 <- roffset+u1
  b1 <- roffset-u1
  a2 <- -roffset+u1
  b2 <- -roffset-u1
  for (angle in seq(-180,180,2)){
    t <- angle*pi/180
    print(c(t,offset,AUC(t,a1,b1,a1-b1)+AUC(t,a2,b2,a2-b2)))
    df <- rbind(df,c((AUC(t,a1,b1,a1-b1)+AUC(t,a2,b2,a2-b2)),t,roffset))
  }
}

colnames(df) <- c("auc","angle","offset")
df$angle <- df$angle*180/pi
df$offset <- df$offset*180/pi
ggplot(df, aes(color=auc))+
  geom_tile(aes(x=angle,y=offset))+
  scale_color_viridis_c()

#test 3 -1 gun with rather heavy damage fixed to the front of the ship, and 2 guns with variable offset, one with double damage

df <- data.frame()
for (offset in seq(1,180,2)){
  roffset <- offset*pi/180
  a1 <- roffset+u1
  b1 <- roffset-u1
  a2 <- -roffset+u1
  b2 <- -roffset-u1
  a3 <- 0
  b3 <- 0
  for (angle in seq(-180,180,2)){
    t <- angle*pi/180
    df <- rbind(df,c(AUC(t,a1,b1,a1-b1)+2*AUC(t,a2,b2,a2-b2)+3*AUC(t,a3,b3,30*pi/180),t,roffset))
  }
}

colnames(df) <- c("auc","angle","offset")
df$angle <- df$angle*180/pi
df$offset <- df$offset*180/pi
ggplot(df, aes(color=auc))+
  geom_tile(aes(x=angle,y=offset))+
  scale_color_viridis_c()

[close]

Well, another possibility is to drop the normal distribution from the calculation which is probably how the AI does it considering the real game doesn't have a normal distribution on top. Then the problem becomes just one of finding the highest among stacked boxes, so almost trivial (it is find all overlaps of guns' firing arcs when applicable and then count which of the overlaps or single guns has highest dps and you are done).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 17, 2022, 11:59:52 PM
Thinking about what Liral said, that brute force is sometimes faster, I decided to apply brute force since I think the best I can do without a few more analysis courses is present a piecewise solution (since the continuous replacement functions for the piecewise I figured out were pretty non-trivial).

So here is the brute-force solution in hopefully human-readable code using our previous functions, slightly augmented.

Code

#create a vector containing angles from -180 to 180
aucvector <- seq(-180,180)


#standard things
#dominator, hullhp, shieldregen, shieldmax, startingarmor, widthinpixels, armorcells, shieldwidth, shieldefficacy, shieldupkeep
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#engagementrange
range <- 1000

#fudge factor
errorsd <- 0.05
#the fudge factor should be a function of range (more error in position at greater range), but not a function of weapon firing angle, and be expressed in terms of pixels
error <- errorsd*range
G <- function(y) return(y*pnorm(y) + dnorm(y))
#a is the SD of the normal distribution and b is the parameter of the uniform distribution
#we add the special cases to this function
hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(min(1,b-abs(z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#compute maximum and minimum mean coordinates
#weapons: damage, facing (deg), tracking range (deg), spread
weapon1 <- c(10,-30,60,10)
weapon2 <- c(10,60,30,5)
weapon3 <- c(20,0,60,0)
weapon4 <- c(20,90,60,0)
weapon5 <- c(5,0,180,0)

#data frame of weapons
weapons <- data.frame()
for (i in 1:5) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("damage","facing","trackingrange","spread")
weapons

#now compute maximum and minimum mean by computing facing+trackingrange/2-spread/2 and facing-trackingrange/2+spread/2
weapons <- cbind(weapons, maxmean=(weapons$facing+weapons$trackingrange/2-weapons$spread/2))
weapons <- cbind(weapons, minmean=(weapons$facing-weapons$trackingrange/2+weapons$spread/2))

#function to transform hit coordinates so mean is aligned with 0, meaning we can use our hit dist fucntion
transform_hit_coord <- function(angle, weapon) return(max(weapon$minmean, min(weapon$maxmean,angle)))

#necessary transformations
segment_to_deg <- function(seg) return(seg/range*360/pi)
deg_to_segment <- function(deg) return(deg*range/360*pi)

#now we have all that we need, so compute the expected auc for degree facings

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

sumauc <- function(angle) {
  summed_auc <- 0
  shipwidth <- segment_to_deg(ship[5])/2
  derror <- segment_to_deg(error)

  for (i in 1:length(weapons[,1])){
    summed_auc <- summed_auc + hit_probability_coord_lessthan_x(deg_to_segment(transformed_angle(angle,weapons[i,])+shipwidth),error,deg_to_segment(weapons[i,4]))-
      hit_probability_coord_lessthan_x(deg_to_segment(transformed_angle(angle,weapons[i,])-shipwidth),error,deg_to_segment(weapons[i,4]))
  }
  return(summed_auc)
}


applied <- sapply(aucvector,FUN=sumauc)
plot(sapply(aucvector,FUN=sumauc),xlab="angle",ylab="dps")
abline(v=median(which(applied == max(applied))))

(https://i.ibb.co/xXBg0mR/image.png) (https://ibb.co/TY70SKF)
(vertical line = choice of rotation)

The main idea is transform coordinates of the enemy ship to the coordinates of the dist median (ie. enemy ship angle from angle wrt our ship to angle from dist median such that angle of dist median = 0), which you find by considering it is equal to target's center when in tracking range, max turret turn angle - spread/2 when above tracking range, and min turret turn angle + spread/2 when below tracking range, since the mean of the uniform dist is also the mean of the convolved dist. Then you select the median rotation of those which correspond to max dps values.

In this case we had 5 guns, with the following parameters

#weapons: damage, facing (deg), tracking range (deg), spread
weapon1 <- c(10,-30,60,10)
weapon2 <- c(10,60,30,5)
weapon3 <- c(20,0,60,0)
weapon4 <- c(20,90,60,0)
weapon5 <- c(5,0,180,0)


To continue from here, you then save the angle of choice and use the normal hit distribution function, but passing the cells' boundary angles through the transform function for each weapon to get the final hit distribution over the enemy ship from our guns at that angle.

Another plot: this ship has 11 guns that can track over 90 degree intervals and are spaced at 36 degree intervals. (note: the x axis is fixed here, was not centered in last).


#weapons: damage, facing (deg), tracking range (deg), spread
weapon1 <- c(10,-180,90,0)
weapon2 <- c(10,-144,90,0)
weapon3 <- c(10,-108,90,0)
weapon4 <- c(10,-72,90,0)
weapon5 <- c(10,-36,90,0)
weapon6 <- c(10,0,90,0)
weapon7 <- c(10,180,90,0)
weapon8 <- c(10,144,90,0)
weapon9 <- c(10,108,90,0)
weapon10 <- c(10,72,90,0)
weapon11 <- c(10,36,90,0)

(https://i.ibb.co/D5C8dyw/image.png) (https://ibb.co/m6qXpwh)

This shows that taking the naive median of the max points is actually not satisfactory. Instead we should prefer to choose specifically the middle one (or lower middle, if two exist). Should also apply a little rounding. There is also a problem that the angle is not considered properly, in that angles over 180 do not map to -180+(angle-180) etc. That requires some rewriting paying attention to the cyclical nature of angles but does not change the basic idea. Here is one way to do it.

Code
aucvector <- seq(-360,360)

angles <- seq(-180,180)
applied <- sapply(aucvector,FUN=sumauc)
for (i in 1:360) applied[i] <- applied[i]+applied[i+360]
applied <- applied[1:361]
applied <- c(tail(applied,181),head(applied,180))
plot(applied,x=angles,xlab="angle",ylab="dps")
abline(v=angles[which(round(applied,3) == round(max(applied),3))[ceiling(length(which(round(applied,3) == round(max(applied),3)))/2)]])


(https://i.ibb.co/DkKFprj/image.png) (https://ibb.co/mtzK8NP)

For a final test some completely random weapons

weapon1 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon2 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon3 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon4 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon5 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon6 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon7 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon8 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon9 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))
weapon10 <- c(runif(1,0,10),runif(1,-180,180),runif(1,0,360),runif(1,0,30))

(https://i.ibb.co/DL9vDPq/image.png) (https://ibb.co/hCB4Rxq)
(https://i.ibb.co/26BBv51/image.png) (https://ibb.co/dcyyP7w)

Edit: fixed wraparound, also I'm adding the fixed script as an attachment to this.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 18, 2022, 02:51:30 PM
I have had to struggle for about an hour to translate the attached code because of its format and organization, and I still can't translate all of it.  I imagine that you wrote it as you would have written an informal proof you were explaining to someone on a whiteboard: starting from the beginning, defining functions and variables as needed, and using the shortest variable names possible.  Had we been chatting at the whiteboard, I would have understood perfectly, but code is read by oneself, so alas I had to struggle.  Had you organized your code as follows, I could have translated it in minutes (besides the dataframe and graphing parts, which would be long and difficult regardless).

Code
import this_library
import that_library


GLOBAL_CONSTANT_NAME = some_value
OTHER_GLOBAL_CONSTANT_NAME = other value


hereIsAFunction = function() { it_does_something }


hereIsAnotherFunction = function() {it_does_something_else }


main = function() {
    make_the_program_go
    someArrayName = c()
    for (i in 1:10) {
        cbind(c, somethingToAdd)
    }
}
main()

Think of code as a textbook chapter proof, of which the interleaved lines and paragraphs of natural natural language have been combined into the letters and squiggles themselves.  For example, an elementary physics textbook author might write

Quote
Consider an object a distance x0 from the origin of an axis, along which it is moving with velocity v0, at time t0.  Were acceleration a uniformly applied to the object for a duration delta_t, the object would afterward be a displacement delta_x from the origin.
delta_x = x0 + v0 * delta_t + 1 / 2 * a * delta_t^2


whereas a Python coder would write

Code
def displacement_under_uniform_acceleration(
        initial_position: float,
        initial_velocity: float,
        acceleration: float,
        acceleration_duration: float) -> float:
    """
    Return the displacement of an object uniformly-accelerated along its velocity axis.

    initial_position - starting displacement of the object from its velocity axis origin
    initial_velocity - starting velocity of the object along its velocity axis
    acceleration - acceleration uniformly applied to the object for some duration
    acceleration_duration - time over which the object is accelerated
    """
    return (initial_position
             + initial_velocity * acceleration_duration
             + 1 / 2 * acceleration * acceleration_duration ** 2)

I would be happy to answer any questions you might have.
Code
Code
#library(ggplot2)
#library(reshape2)
import math
import random


N = 50 / 1000#QUESTION: What is this variable?
W = 110 / 1000#QUESTION: What is this variable?


def G(y): return y * pnorm(y) + dnorm(y)


def Fz(z,  u): return N / 2 / u * (G((z + u) / N) - G((z - u) / N))


def h(x, a, b): return min(a, max(x, b))


def AUC(t, a, b, u):
    """
    QUESTION: What does this function do, and why?
    """
    return Fz(t + W - h(t, a, b), u) - Fz(t - W - h(t, a, b), u)


def hit_probability_coord_lessthan_x(x: float, a: float, b: float) -> float:
    """
   
    Includes special cases.
   
    a - standard deviation of the normal distribution
    b - parameter of the uniform distribution
    """
    if a > 0 or b > 0:
        if a == 0: return min(1, b - abs(x))
        return (pnorm(x, 0, a) if b == 0
               else a / 2 / b * (G((x + b) / a) - G((x - b) / a)))
    return 0 if x < 0 else 1
   

def random_weapon_damage() -> float:
    """
    Return a random damage amount.
    """
    return random.random() * 10
   
   
def random_weapon_facing() -> float:
    """
    Return a random facing in degrees.
    """
    return random.random() * 360 - 180
   
   
def random_weapon_tracking_range() -> float:
    """
    Return a random tracking range in degrees.
    """
    return random.random() * 360
   
   
def random_weapon_spread() -> float:
    """
    Return a random spread in degreees.
    """
    return random.random() * 30   
           
           
def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return hit coordinates transformed to align mean with 0.
   
    Lets us use our hit dist fucntion.
    """
    return max(minimum_mean,  min(maximum_mean, angle))
   
   
def transformed_angle(angle: float, minimum_mean: float, maximum_mean: float):
    """Return the expected auc for degree facings."""
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)
   

def segment_to_deg(seg: float) -> float:
    """Return the degree angle corresponding to a segment."""
    return seg / range * 360 / math.pi
   
   
def deg_to_segment(deg: float) -> float:
    """Return the segment corresponding to a degree angle."""
    return deg * range / 360 * math.pi
   
   
def sum_auc(
        ship: object,
        minimum_means: tuple,
        maximum_means: tuple,
        spreads: tuple,
        angle: float,
        error: float) -> float:
    """
    QUESTION: What does this function do, and why?
    """
    summed_auc = 0
    shipwidth = segment_to_deg(ship[5]) / 2
    derror = segment_to_deg(error)

    for i, _ in enumerate(spreads):
        spread_distance = deg_to_segment(spreads[i])
        middle_angle = transformed_angle(angle, minimum_means[i],
                                         maximum_means[i])
        left_segment = deg_to_segment(middle_angle + shipwidth)
        right_segment = deg_to_segment(middle_angle - shipwidth)
        summed_auc += (hit_probability_coord_lessthan_x(left_segment, error,
                                                        spread_distance)
                       - hit_probability_coord_lessthan_x(right_segment, error,
                                                          spread_distance))
    return summed_auc
   
   
def test_1(u: float):
    offset = 60 * math.pi / 180
    u = 10 * math.pi / 180
    a = offset + u
    b = offset - u
    angles = tuple(angle for angle in range(360))
    auc = tuple((AUC(angle * math.pi / 180, a, b, a - b)) for angle in angles)
    #ggplot(df, aes(y=auc, x=angle))
    #           + geom_line()
    #           + geom_vline(xintercept=a1 * 180 / math.pi)
    #           + geom_vline(xintercept=b1 * 180 / math.pi)


def test_2(u: float):
    """
    2 similar guns dealing equal damage
    """
    degree_angles = tuple(angle for angle in range(-180, 180, 2))
    degree_offsets = tuple(offset for offset in range(0, 180, 2))
    radian_angles = tuple(angle * math.pi / 180 for angle in degree_angles)
    radian_offsets = tuple(offset * math.pi / 180 for offset in degree_offsets)
    aucs = []
    for degree_offset, radian_offset in zip(degree_offsets, radian_offsets):
        a1 = radian_offset + u
        b1 = radian_offset - u
        a2 = u - radian_offset
        b2 = u - radian_offset
        for degree_angle, radian_angle in zip(degree_angles, radian_angles):
            auc = (AUC(radian_angle, a1, b1, a1 - b1)
                   + AUC(radian_angle, a2, b2, a2 - b2))
            print(degree_angle, degree_offset, auc)
            aucs.append(auc)
    #ggplot(df, aes(color=auc))
    #           + geom_tile(aes(x=angle, y=offset))
    #           + scale_color_viridis_c()


def test_3(u: float):
    """
    1 high-damage gun fixed to the front of the ship and 2 guns with
    variable offset, one with double damage
    """
    degree_offsets = tuple(offset for offset in range(0, 180, 2))
    degree_angles = tuple(angle for angle in range(-180, 180, 2))
    radian_offsets = tuple(offset * math.pi / 180 for offset in degree_offsets)
    radian_angles = tuple(angle * math.pi / 180 for angle in degree_angles)
   
    aucs = []
    for degree_offset, radian_offset in zip(degree_offsets, radian_offsets):
        a1 = radian_offset + u
        b1 = radian_offset - u
        a2 = u - radian_offset
        b2 = u - radian_offset
        a3 = 0
        b3 = 0
        for angle in radian_angles:
            aucs.append((AUC(t, a1, b1, a1 - b1)
                         + 2 * AUC(t, a2, b2, a2 - b2)
                         + 3 * AUC(t, a3, b3, 30 * math.pi / 180), t, roffset))
        #ggplot(df, aes(color=auc)
        #           + geom_tile(aes(x=angle, y=offset))
        #           + scale_color_viridis_c())
   

def main():
    runtests = False
    if runtests:
        u = 10 * math.pi / 180
        test_1(u)
        test_2(u)
        test_3(u)
   
    #dominator
    ship = (14000,#hullhp
            500,#shieldregen
            10000,#shieldmax
            1500,#startingarmor
            220,#widthinpixels
            12,#armorcells
            440,#shieldwidth
            1.0,#shieldefficacy
            200)#shieldupkeep
   
    engagement_range = 1000
   
    #fudge factor
    errorsd = 0.05
    #the fudge factor should be a function of range (more error in
    #position at greater range),  but not a function of weapon firing
    #angle,  and be expressed in terms of pixels
    error = errorsd * engagement_range
   
    ship_count = 10

    damage = tuple(random_weapon_damage() for _ in range(ship_count))
    spread = tuple(random_weapon_spread() for _ in range(ship_count))
    facing = tuple(random_weapon_facing() for _ in range(ship_count))
    tracking_range = tuple(random_weapon_tracking_range() for _ in
                           range(ship_count))
    maximum_means = tuple(facing[i] + (tracking_range[i] - spread[i]) / 2 for i
                          in range(ship_count))
    minimum_means = tuple(facing[i] - (tracking_range[i] + spread[i]) / 2 for i
                          in range(ship_count))
   
    angles = tuple(i for i in range(-180, 181))
    #applied = sapply(angles, FUN=sum_auc)
    #plot(sapply(aucvector, FUN=sumauc), x=aucvector, xlab="angle", ylab="dps")
    #abline(v=aucvector[which(round(applied, 3) == round(max(applied), 3))[ceiling(length(which(round(applied, 3) == round(max(applied), 3))) / 2)]])
main()
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 18, 2022, 05:26:58 PM
Oh, sorry! But on the plus side I think your explanation of how code should be written makes perfect sense and is very succinct and good. I'll try to adhere to it. Your imagining of how I thought about code was also quite on the spot. Also the code included the previous test I did too - that really shouldn't have been to avoid confusion, but I didn't realize you would end up translating it. Anyway, let's comment this

Code
#library(ggplot2)
#library(reshape2)
import math
import random


N = 50 / 1000#QUESTION: What is this variable?
W = 110 / 1000#QUESTION: What is this variable?

#Answer: these refer to the latex math. The first part was a direct translation of math to code and wasn't meant to be translated. Other than G(y), which we need defined, but should already exist with its own name in Py, ignore everything between this comment and next.

def G(y): return y * pnorm(y) + dnorm(y)


def Fz(z,  u): return N / 2 / u * (G((z + u) / N) - G((z - u) / N))


def h(x, a, b): return min(a, max(x, b))


def AUC(t, a, b, u):
    """
    QUESTION: What does this function do, and why?
    """
    return Fz(t + W - h(t, a, b), u) - Fz(t - W - h(t, a, b), u)

# A: Part of testing - ignore

def hit_probability_coord_lessthan_x(x: float, a: float, b: float) -> float:
    """
   
    Includes special cases.
   
    a - standard deviation of the normal distribution
    b - parameter of the uniform distribution
This needs an additional correction in the line about 0 sd, so minimum is 0 and also it should in fact be abs(b/2+x), but we do not need the abs here when the lower bound is 0 anyway. That means calculating the part of the box of area 1 from -b/2 to b/2 that is below x. Whoops!

A version of this should already exist with its own name in py. It is the CDF of the hit distribution.
    """
    if a > 0 or b > 0:
        if a == 0: return max(0,min(1, b/2+x))
        return (pnorm(x, 0, a) if b == 0
               else a / 2 / b * (G((x + b) / a) - G((x - b) / a)))
    return 0 if x < 0 else 1
   
*

def random_weapon_damage() -> float:
    """
    Return a random damage amount.
    """
    return random.random() * 10
   
   
def random_weapon_facing() -> float:
    """
    Return a random facing in degrees.
    """
    return random.random() * 360 - 180
   
   
def random_weapon_tracking_range() -> float:
    """
    Return a random tracking range in degrees.
    """
    return random.random() * 360
   
   
def random_weapon_spread() -> float:
    """
    Return a random spread in degreees.
    """
    return random.random() * 30   
           
           
def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the mean of the hit distribution, when the gun attempts to track the target.
   
    Lets us use our hit dist fucntion.
    """
    return max(minimum_mean,  min(maximum_mean, angle))
   
   
def transformed_angle(angle: float, minimum_mean: float, maximum_mean: float):
    """Return the angle between the target and the mean of the hit distribution, with sign.

"""
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)
   

def segment_to_deg(seg: float) -> float:
    """Return the degree angle corresponding to a segment."""
    return seg / range * 360 / math.pi
   
   
def deg_to_segment(deg: float) -> float:
    """Return the segment corresponding to a degree angle."""
    return deg * range / 360 * math.pi
   
   
def sum_auc(
        ship: object,
        minimum_means: tuple,
        maximum_means: tuple,
        spreads: tuple,
        angle: float,
        error: float) -> float:
    """
    QUESTION: What does this function do, and why?
    A: for all weapons, sum the probability to hit target ship given target ship angle. That is, CDF (ship's angle from mean of distribution + width/2) - CDF (ship's angle from mean of distribution - width/2). This corresponds to an integral of the probability distribution from the ship's left edge to right from our perspective for each gun. Additionally, this should be scaled by dps so that higher dps guns weigh more in decisions about rotation. If that was missing from my code, it is a mistake. The summand should be dps*probability for each gun (since dps from gun = dps * probability of hitting)

We have previously written the probability distribution in terms of pixels of target (ignoring curvature). However, it is convenient to refer to angles to describe gun placement. Hence the need for seg to deg and deg to seg translation. Derror was a vestige of an alternative formulation that we translate the sd to degrees. Whichever way works so long as all parameters are either angle or distance when summing, transforming or passing to hit distribution function.

    """
    summed_auc = 0
    shipwidth = segment_to_deg(ship[5]) / 2

    for i, _ in enumerate(spreads):
        spread_distance = deg_to_segment(spreads[i])
        middle_angle = transformed_angle(angle, minimum_means[i],
                                         maximum_means[i])
        left_segment = deg_to_segment(middle_angle + shipwidth)
        right_segment = deg_to_segment(middle_angle - shipwidth)
        summed_auc += (hit_probability_coord_lessthan_x(left_segment, error,
                                                        spread_distance)
                       - hit_probability_coord_lessthan_x(right_segment, error,
                                                          spread_distance))
    return summed_auc
   
#do not translate part between this comment and next
   
def test_1(u: float):
    offset = 60 * math.pi / 180
    u = 10 * math.pi / 180
    a = offset + u
    b = offset - u
    angles = tuple(angle for angle in range(360))
    auc = tuple((AUC(angle * math.pi / 180, a, b, a - b)) for angle in angles)
    #ggplot(df, aes(y=auc, x=angle))
    #           + geom_line()
    #           + geom_vline(xintercept=a1 * 180 / math.pi)
    #           + geom_vline(xintercept=b1 * 180 / math.pi)


def test_2(u: float):
    """
    2 similar guns dealing equal damage
    """
    degree_angles = tuple(angle for angle in range(-180, 180, 2))
    degree_offsets = tuple(offset for offset in range(0, 180, 2))
    radian_angles = tuple(angle * math.pi / 180 for angle in degree_angles)
    radian_offsets = tuple(offset * math.pi / 180 for offset in degree_offsets)
    aucs = []
    for degree_offset, radian_offset in zip(degree_offsets, radian_offsets):
        a1 = radian_offset + u
        b1 = radian_offset - u
        a2 = u - radian_offset
        b2 = u - radian_offset
        for degree_angle, radian_angle in zip(degree_angles, radian_angles):
            auc = (AUC(radian_angle, a1, b1, a1 - b1)
                   + AUC(radian_angle, a2, b2, a2 - b2))
            print(degree_angle, degree_offset, auc)
            aucs.append(auc)
    #ggplot(df, aes(color=auc))
    #           + geom_tile(aes(x=angle, y=offset))
    #           + scale_color_viridis_c()


def test_3(u: float):
    """
    1 high-damage gun fixed to the front of the ship and 2 guns with
    variable offset, one with double damage
    """
    degree_offsets = tuple(offset for offset in range(0, 180, 2))
    degree_angles = tuple(angle for angle in range(-180, 180, 2))
    radian_offsets = tuple(offset * math.pi / 180 for offset in degree_offsets)
    radian_angles = tuple(angle * math.pi / 180 for angle in degree_angles)
   
    aucs = []
    for degree_offset, radian_offset in zip(degree_offsets, radian_offsets):
        a1 = radian_offset + u
        b1 = radian_offset - u
        a2 = u - radian_offset
        b2 = u - radian_offset
        a3 = 0
        b3 = 0
        for angle in radian_angles:
            aucs.append((AUC(t, a1, b1, a1 - b1)
                         + 2 * AUC(t, a2, b2, a2 - b2)
                         + 3 * AUC(t, a3, b3, 30 * math.pi / 180), t, roffset))
        #ggplot(df, aes(color=auc)
        #           + geom_tile(aes(x=angle, y=offset))
        #           + scale_color_viridis_c())
 
#tests end
#with the tests using direct translations from the math gone, the rest of the code describes a test of what we have written using 10 random weapons.

def main():
   
   
    #dominator
    ship = (14000,#hullhp
            500,#shieldregen
            10000,#shieldmax
            1500,#startingarmor
            220,#widthinpixels
            12,#armorcells
            440,#shieldwidth
            1.0,#shieldefficacy
            200)#shieldupkeep
   
    engagement_range = 1000
   
    #fudge factor
    errorsd = 0.05
    #the fudge factor should be a function of range (more error in
    #position at greater range),  but not a function of weapon firing
    #angle,  and be expressed in terms of pixels
    error = errorsd * engagement_range
   
    ship_count = 10

    damage = tuple(random_weapon_damage() for _ in range(ship_count))
    spread = tuple(random_weapon_spread() for _ in range(ship_count))
    facing = tuple(random_weapon_facing() for _ in range(ship_count))
    tracking_range = tuple(random_weapon_tracking_range() for _ in
                           range(ship_count))
    maximum_means = tuple(facing[i] + (tracking_range[i] - spread[i]) / 2 for i
                          in range(ship_count))
    minimum_means = tuple(facing[i] - (tracking_range[i] + spread[i]) / 2 for i
                          in range(ship_count))

# the next part describes one lazy way of handling the whole computations
   
#define all possible angles for the target ship. This should actually probably be -179 to 180 for consistency

    angles = tuple(i for i in range(-180, 181))

#for each angle, perform the sum described above.
#note that this version will incorrectly not wrap the angle around!
# i fixed this by calculating from -360 to 360, then summing -360 and 0, -359 and 1, etc. which covers all possibilities because guns are placed between -180 (should be -179) so minimum trackable angle possible is -360 (-359), corresponding to 0, and maximum+360). Then got the final result for angles -180 to 180 by a sub-vector selection and rotation operation. This can really be done any way you please, so long as you take note of that a gun at -179 with 30 tracking range should also be able to fire at a target at +151 degrees, etc.

    #applied = sapply(angles, FUN=sum_auc)
    #plot(sapply(aucvector, FUN=sumauc), x=aucvector, xlab="angle", ylab="dps")

# in my version we now find the angle of choice by listing the degrees where dps == maximum, with rounding to avoid errors due to precision, and select the angle corresponding to the middle of the list (list length/2 rounded up)
#use any similar method hers
#for the demo, we plot a vertical line at this point using abline
# in the real code, use this as the angle of target to ship for highest dps.

    #abline(v=aucvector[which(round(applied, 3) == round(max(applied), 3))[ceiling(length(which(round(applied, 3) == round(max(applied), 3))) / 2)]])
main()

Incidentally this might be a fun function in itself: show a ship variant's dps profile at around it at angles to visually find weak and strong points.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 19, 2022, 11:13:15 AM
I don't think these results are right.  Also, would you please tell me what auc stands for?
Code
Code
#library(ggplot2)
#library(reshape2)
import math
import random
import statistics


def G(x, normal_distribution):
    return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)
   
   
def segment_to_deg(seg: float, distance: float) -> float:
    """Return the degree angle corresponding to a segment."""
    return seg / distance * 360 / math.pi
   
   
def deg_to_segment(deg: float, distance: float) -> float:
    """Return the segment corresponding to a degree angle."""
    return deg * distance / 360 * math.pi
           
           
def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the mean of the hit distribution,
    when the gun attempts to track the target.
   
    Lets us use our hit dist fucntion.
    """
    return max(minimum_mean,  min(maximum_mean, angle))
   
   
def transformed_angle(angle: float, minimum_mean: float, maximum_mean: float):
    """
    Return the angle between the target and the mean of the hit
    distribution, with sign.
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)
   
   
def hit_probability_coord_lessthan_x(
        x: float,
        standard_deviation: float,
        uniform_parameter: float) -> float:
    """
   
    Includes special cases.
   
    a - standard deviation of the normal distribution
    b - parameter of the uniform distribution
   
    This needs an additional correction in the line about 0 sd,
    so minimum is 0 and also it should in fact be abs(b/2+x),
    but we do not need the abs here when the lower bound is 0 anyway.
    That means calculating the part of the box of area 1 from
    -b/2 to b/2 that is below x. Whoops!

    A version of this should already exist with its own name in py.
    It is the CDF of the hit distribution.
    """
    if standard_deviation > 0 or uniform_parameter > 0:
        if standard_deviation == 0:
            return max(0, min(1, x + uniform_parameter / 2))
        normal_distribution = statistics.NormalDist(0, standard_deviation)
        if uniform_parameter == 0: return normal_distribution.cdf(x, 0, a)
        a = standard_deviation / 2 / uniform_parameter
        b = uniform_parameter / standard_deviation
        c = x / standard_deviation
        return a * (G((b + c), normal_distribution)
                    - G((b - c), normal_distribution))
    return x < 0


def auc(middle_angle: float,
        spread_angle: float,
        ship_angle: float,
        distance: float,
        error_distance: float) -> float:
    """
    Return the probability for a weapons to hit the target ship given
    target ship angle.
   
    probability = (
        CDF (ship's angle from mean of distribution + width/2)
        - CDF (ship's angle from mean of distribution - width/2))
       
    This difference corresponds to an integral of the probability
    distribution from the ship's left edge to right from our
    perspective for the weapon.
   
    We have previously written the probability distribution in terms
    of pixels of target, ignoring curvature, but describing gun
    placement by angle is convenient. Hence the need for seg to deg
    and deg to seg translation. Derror was a vestige of an alternative
    formulation that we translate the sd to degrees. Whichever way
    works so long as all parameters are either angle or distance when
    summing, transforming or passing to hit distribution function.
    """
    spread_distance = deg_to_segment(spread_angle, distance)
    left_segment = deg_to_segment(middle_angle + ship_angle, distance)
    right_segment = deg_to_segment(middle_angle - ship_angle, distance)
    return (hit_probability_coord_lessthan_x(left_segment, error_distance,
                                             spread_distance)
            - hit_probability_coord_lessthan_x(right_segment, error_distance,
                                               spread_distance))


def random_weapon_damage() -> float:
    """
    Return a random damage amount.
    """
    return random.random() * 10
   
   
def random_weapon_facing() -> float:
    """
    Return a random facing in degrees.
    """
    return random.random() * 360 - 180
   
   
def random_weapon_tracking_range() -> float:
    """
    Return a random tracking range in degrees.
    """
    return random.random() * 360
   
   
def random_weapon_spread() -> float:
    """
    Return a random spread in degreees.
    """
    return random.random() * 30


def main():
    """
    Test what we have written using 10 random weapons.
    """
   
    #dominator
    ship = (14000,#hullhp
            500,#shieldregen
            10000,#shieldmax
            1500,#startingarmor
            220,#widthinpixels
            12,#armorcells
            440,#shieldwidth
            1.0,#shieldefficacy
            200)#shieldupkeep
   
    engagement_range = 1000
   
    #fudge factor
    errorsd = 0.05
    #the fudge factor should be a function of range (more error in
    #position at greater range),  but not a function of weapon firing
    #angle,  and be expressed in terms of pixels
    error = errorsd * engagement_range
   
    ship_count = 10

    damages = tuple(random_weapon_damage() for _ in range(ship_count))
    spreads = tuple(random_weapon_spread() for _ in range(ship_count))
    facings = tuple(random_weapon_facing() for _ in range(ship_count))
    tracking_ranges = tuple(random_weapon_tracking_range() for _ in
                            range(ship_count))
    maximum_means = tuple(facings[i] + (tracking_ranges[i] - spreads[i]) / 2 for
                          i in range(ship_count))
    minimum_means = tuple(facings[i] - (tracking_ranges[i] + spreads[i]) / 2 for
                          i in range(ship_count))

    #the next part describes one lazy way of handling the whole
    #computations
   
    #define all possible angles for the target ship. This should
    #actually probably be -179 to 180 for consistency note that this
    #version will incorrectly not wrap the angle around! I fixed this
    #by calculating from -360 to 360, then summing -360 and 0,
    #-359 and 1, etc. which covers all possibilities because guns are
    #placed between -180 (should be -179) so minimum trackable angle
    #possible is -360 (-359), corresponding to 0, and maximum+360).
    #Then got the final result for angles -180 to 180 by a sub-vector
    #selection and rotation operation. This can really be done any way
    #you please, so long as you take note of that a gun at -179 with
    #30 tracking range should also be able to fire at a target at +151
    #degrees, etc.

    #for each angle, perform the sum described above.
    applied = tuple(sum(auc(transformed_angle(facing, minimum_mean, maximum_mean
        ), spread, ship_angle, engagement_range, error) for minimum_mean,
        maximum_mean, spread, facing in zip(minimum_means, maximum_means,
        spreads, facings)) for ship_angle in tuple(i for i in range(-180, 181)))
       
    for element in applied: print(element)
   
       
    #plot(sapply(aucvector, FUN=sumauc), x=aucvector, xlab="angle", ylab="dps")

    #in my version we now find the angle of choice by listing the
    #degrees where dps == maximum, with rounding to avoid errors due
    #to precision, and select the angle corresponding to the middle
    #of the list (list length/2 rounded up)
    #use any similar method hers
    #for the demo, we plot a vertical line at this point using abline
    #in the real code, use this as the angle of target to ship for highest dps.

    #abline(v=aucvector[which(round(applied, 3) == round(max(applied), 3))[ceiling(length(which(round(applied, 3) == round(max(applied), 3))) / 2)]])
main()
[close]
Results
-213.6526924937567
-212.47788171395493
-211.30289120034305
-210.12772135322342
-208.9523725812908
-207.77684530162963
-206.60113993971098
-205.42525692938887
-204.24919671289598
-203.0729597408388
-201.89654647219203
-200.7199573742928
-199.5431929228337
-198.36625360185585
-197.18913990374105
-196.0118523292033
-194.83439138727996
-193.65675759532192
-192.47895147898376
-191.3009735722128
-190.1228244172379
-188.94450456455743
-187.76601457292682
-186.58735500934543
-185.40852644904285
-184.22952947546472
-183.05036468025767
-181.87103266325383
-180.69153403245508
-179.51186940401618
-178.33203940222745
-177.15204465949702
-175.97188581633264
-174.79156352132225
-173.6110784311146
-172.43043121039932
-171.2496225318856
-170.06865307628115
-168.88752353227028
-167.70623459649144
-166.5247869735136
-165.34318137581312
-164.1614185237493
-162.97949914553934
-161.79742397723294
-160.61519376268643
-159.43280925353594
-158.2502712091705
-157.06758039670393
-155.88473759094654
-154.70174357437645
-153.51859913711004
-152.3353050768717
-151.15186219896322
-149.96827131623277
-148.78453324904308
-147.60064882523875
-146.41661888011396
-145.23244425637856
-144.04812580412408
-142.8636643807894
-141.67906085112529
-140.49431608715892
-139.30943096815764
-138.124406380592
-136.93924321809862
-135.75394238144204
-134.56850477847658
-133.3829313241071
-132.19722294024973
-131.01138055579162
-129.8254051065504
-128.6392975352332
-127.45305879139474
-126.26668983139544
-125.08019161835848
-123.89356512212687
-122.70681131921941
-121.51993119278656
-120.33292573256574
-119.14579593483602
-117.95854280237226
-116.77116734439905
-115.58367057654354
-114.39605352078864
-113.20831720542483
-112.02046266500213
-110.83249094028139
-109.64440307818474
-108.45620013174626
-107.26788316006163
-106.07945322823737
-104.89091140733981
-103.70225877434352
-102.51349641207908
-101.32462540918087
-100.13564686003367
-98.94656186471964
-97.75737152896416
-96.5680769640816
-95.37867928692063
-94.18917961980895
-92.99957909049765
-91.80987883210513
-90.62007998306078
-89.43018368704791
-88.24019109294652
-87.05010335477553
-85.85992163163466
-84.66964708764598
-83.47928089189489
-82.28882421837072
-81.09827824590724
-79.90764415812235
-78.71692314335776
-77.52611639461794
-76.33522510950924
-75.1442504901778
-73.9531937432481
-72.7620560797603
-71.57083871510774
-70.37954286897391
-69.18816976526911
-67.99672063206674
-66.80519670153919
-65.61359920989369
-64.42192939730751
-63.23018850786294
-62.03837778948213
-60.84649849386134
-59.65455187640507
-58.46253919615995
-57.27046171574808
-56.07832070130035
-54.88611742238936
-53.69385315196204
-52.50152916627209
-51.30914674481202
-50.11670717024508
-48.92421172833681
-47.73166170788647
-46.539058400658035
-45.346403101311225
-44.153697107331965
-42.96094171896291
-41.768138239133606
-40.575287973390466
-39.38239222982651
-38.18945231901093
-36.99646955391849
-35.8034452498586
-34.610380724404344
-33.4172772973213
-32.22413629049606
-31.030959027864768
-29.837746835341324
-28.644501040745567
-27.45122297373115
-26.257913965713445
-25.064575349797163
-23.871208460703887
-22.6778146346995
-21.48439520952144
-20.29095152430588
-19.097484919514745
-17.90399673686271
-16.71048831924394
-15.51696101065895
-14.323416156141144
-13.12985510168348
-11.936279194164888
-10.742689781276736
-9.549088211449149
-8.355475833777342
-7.161853997947817
-5.968224054164585
-4.774587353075299
-3.5809452456973805
-2.387299083344086
-1.1936502175505876
0.0
1.1936502175505876
2.387299083344086
3.5809452456973805
4.774587353075299
5.968224054164585
7.161853997947817
8.355475833777342
9.549088211449149
10.742689781276736
11.936279194164888
13.12985510168348
14.323416156141144
15.51696101065895
16.71048831924394
17.90399673686271
19.097484919514745
20.29095152430588
21.48439520952144
22.6778146346995
23.871208460703887
25.064575349797163
26.257913965713445
27.45122297373115
28.644501040745567
29.837746835341324
31.030959027864768
32.22413629049606
33.4172772973213
34.610380724404344
35.8034452498586
36.99646955391849
38.18945231901093
39.38239222982651
40.575287973390466
41.768138239133606
42.96094171896291
44.153697107331965
45.346403101311225
46.539058400658035
47.73166170788647
48.92421172833681
50.11670717024508
51.30914674481202
52.50152916627209
53.69385315196204
54.88611742238936
56.07832070130035
57.27046171574808
58.46253919615995
59.65455187640507
60.84649849386134
62.03837778948213
63.23018850786294
64.42192939730751
65.61359920989369
66.80519670153919
67.99672063206674
69.18816976526911
70.37954286897391
71.57083871510774
72.7620560797603
73.9531937432481
75.1442504901778
76.33522510950924
77.52611639461794
78.71692314335776
79.90764415812235
81.09827824590724
82.28882421837072
83.47928089189489
84.66964708764598
85.85992163163466
87.05010335477553
88.24019109294652
89.43018368704791
90.62007998306078
91.80987883210513
92.99957909049765
94.18917961980895
95.37867928692063
96.5680769640816
97.75737152896416
98.94656186471964
100.13564686003367
101.32462540918087
102.51349641207908
103.70225877434352
104.89091140733981
106.07945322823737
107.26788316006163
108.45620013174626
109.64440307818474
110.83249094028139
112.02046266500213
113.20831720542483
114.39605352078864
115.58367057654354
116.77116734439905
117.95854280237226
119.14579593483602
120.33292573256574
121.51993119278656
122.70681131921941
123.89356512212687
125.08019161835848
126.26668983139544
127.45305879139474
128.6392975352332
129.8254051065504
131.01138055579162
132.19722294024973
133.3829313241071
134.56850477847658
135.75394238144204
136.93924321809862
138.124406380592
139.30943096815764
140.49431608715892
141.67906085112529
142.8636643807894
144.04812580412408
145.23244425637856
146.41661888011396
147.60064882523875
148.78453324904308
149.96827131623277
151.15186219896322
152.3353050768717
153.51859913711004
154.70174357437645
155.88473759094654
157.06758039670393
158.2502712091705
159.43280925353594
160.61519376268643
161.79742397723294
162.97949914553934
164.1614185237493
165.34318137581312
166.5247869735136
167.70623459649144
168.88752353227028
170.06865307628115
171.2496225318856
172.43043121039932
173.6110784311146
174.79156352132225
175.97188581633264
177.15204465949702
178.33203940222745
179.51186940401618
180.69153403245508
181.87103266325383
183.05036468025767
184.22952947546472
185.40852644904285
186.58735500934543
187.76601457292682
188.94450456455743
190.1228244172379
191.3009735722128
192.47895147898376
193.65675759532192
194.83439138727996
196.0118523292033
197.18913990374105
198.36625360185585
199.5431929228337
200.7199573742928
201.89654647219203
203.0729597408388
204.24919671289598
205.42525692938887
206.60113993971098
207.77684530162963
208.9523725812908
210.12772135322342
211.30289120034305
212.47788171395493
213.6526924937567
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 19, 2022, 11:50:24 AM
Auc = area under (the) curve.

Those results certainly don't seem likely. It certainly shouldn't be the case that expected dps is linearly dependent on angle and possibly negative. I wonder if something is getting summed incorrectly somewhere? But I couldn't immediately find it. Tell you what, I'll write an actually clear and properly annotated version of the original tomorrow.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Salter on December 19, 2022, 04:00:03 PM
My experience with missile conquest has largely been to give it a locust SRM to overwhelm/swarm a ship's point defense then follow it up with a harpoon/hurricane MIRV missiles. Point defense AI targets the closest thing to it and this gives you the opportunity to slip the real missiles past its defenses. Harpoons and MIRV's pack enough punch that they will inflict significant flux if they hit shields and inflict significant damage if they hit the ship, if nothing else.

Its a ship where having an officer with missile spec and all the related missile mod's installed makes it a top-tier pick in many situations. Even against remnant ordo's and the elite redacted enemies that hang around the coronal hypershunt.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 19, 2022, 09:23:53 PM
My experience with missile conquest has largely been to give it a locust SRM to overwhelm/swarm a ship's point defense then follow it up with a harpoon/hurricane MIRV missiles. Point defense AI targets the closest thing to it and this gives you the opportunity to slip the real missiles past its defenses. Harpoons and MIRV's pack enough punch that they will inflict significant flux if they hit shields and inflict significant damage if they hit the ship, if nothing else.

Its a ship where having an officer with missile spec and all the related missile mod's installed makes it a top-tier pick in many situations. Even against remnant ordo's and the elite redacted enemies that hang around the coronal hypershunt.

Agreed. The Conquest is quite suitable for Remnant farming. If you field a fleet of only Conquests or Conquests with frigates, then I don't think enemy PD is really much of a problem. The sheer volume of incoming missiles will overwhelm point defenses. I should give Locusts a try in Remnant farming though, just to see if it does improve things. I would probably replace the Hurricane, so Squall-Locust, since this was also the best combination in testing.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 20, 2022, 12:03:19 AM
All right Liral, here is commented code that I hope that you will find more helpful. Also as an attachment and with reasonable sample output. This is, of course, what I should have written in the first place - apologies. I also fixed the problem with dps not being considered and made the wraparound more accessible to human cognition, although the process is equivalent but no longer involves rotating a vector. I added the ability to compute hit distributions.

Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.

#Section 1. general considerations
#We will test against a Dominator. We wish to integrate this code with the other modules eventually,
#so we will use the full ship definition, even though only the width is needed.

ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
range <- 1000

#For testing, we will give these parameters for weapons:
#damage, facing (deg), tracking range (deg), spread
#collect them in a data frame

#for this test we will add two weapons that are slightly angled away from each other and able to overlap
#in the exact middle
weapon1 <- c(100, -10, 20, 5)
weapon2 <- c(100, 10, 20, 5)
# and some rear mounted point defense weapons to make sure the wraparound, extreme angles are correct
weapon3 <- c(30, -160, 20, 0)
weapon4 <- c(30, 180, 20, 0)
weapon5 <- c(30, 160, 20, 0)
weapon6 <- c(120, 90, 0, 5)
#lastly a weapon fixed at the ship front that has higher damage by itself, to test angle choice logic
#the correct choice should be to pick the overlap of the two weapons
noweapons <- 6
weapons <- data.frame()
for (i in 1:noweapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("damage","facing","trackingrange","spread")


#We will use the same parameter for the normal distribution as we do in the other modules.
#Note that this results in the SD of the normal distribution being in pixels.

errorsd <- 0.05
error <- errorsd*range

#We need the helper function G used to define the cumulative distribution function of the convolved
#normal distribution.

G <- function(y) return(y*pnorm(y) + dnorm(y))

#The next function will give the cumulative distribution function of the hit distribution.
#Note that this is equal to integral from -inf to z of the probability distribution, so, applying
#the fundamental theorem of calculus, we can say that the integral from ship lower edge to ship upper edge
#is CDF(upper edge)-CDF(lower edge). We have used the variable z out of convention, but the argument is
#a real number, hence we refer to x in the function title.

#the first argument of the function is a real number. The second argument is the SD of the normal
#distribution. The third argument is the parameter of the uniform distribution.

#We note the following special cases: In case the parameter of the uniform distribution is 0,
#no convolution happens and the distribution is normal. In case the parameter of the normal distribution
#is 0, the distribution is uniform and no convolution happens. In case both are 0, the distribution is
#trivial (the CDF is equal to a step function going from 0 to 1 at z=0)

#Summary: Input: z, a real number, a, sd of normal distribution, b, radius of uniform distribution
#Output: CDF of z. For random variable Z from U(-b,b)+N(0,a): Probability that Z < z.

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#It is convenient to refer to the width of the enemy ship in pixels, but to the placement and
#tracking ability of turrets as angles. Therefore we will need to be able to convert between the two.
#Note that by assumption we assume we can use the arc to describe the enemy ship.

arc_to_deg <- function(arc) return(arc/range*360/pi)
deg_to_arc <- function(deg) return(deg*range/360*pi)

#Now note that since weapons can never go beyond their maximum turn angle, then if spread stays constant,
#it must be the case that the maximum mean point of the probability distribution of the weapon is given
#by maximum turn angle - spread / 2 and likewise minimum turn angle + spread/2.
#When a weapon tracks the target, it tries to align the mean of the probability distribution with the
#target. Therefore we can find the signed angle of the target from the mean of the distribution by

#first, find the mean point, that is, max mean, when target is above max mean, target, when target
#is between max mean and min mean, min mean, when target is below min mean.

#We will now compute the minimum and maximum means for each weapon and find the median of the
#hit distribution ("transform hit coord" for coordinates of mean of modified dist in
#terms of original dist )

weapons <- cbind(weapons, maxmean=(weapons$facing+weapons$trackingrange/2-weapons$spread/2))
weapons <- cbind(weapons, minmean=(weapons$facing-weapons$trackingrange/2+weapons$spread/2))

transform_hit_coord <- function(angle, weapon) return(max(weapon$minmean, min(weapon$maxmean,angle)))

#then calculate the angle between the mean and target by subtraction. We refer to this as "transforming"
#since we are moving from expressing the angle wrt. ship to wrt. weapon.
transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#Now to orient the ship we wish to find the orientation that has the largest amount of DPS hitting the
#target. This means maximizing dps * probability to hit the target.
#therefore we will calculate the sum of areas under curve of probability distribution over target
#times damage. This is equivalent to taking the integral of ship lower angle to ship greater angle
#over the probability distribution times damage for each weapon. We use the CDF we described above.

#We refer to auc (area under curve, ie. the curve of damage times probability dist,
#giving total damage) for short.

#Input: angle of target ship
#Output: total damage to target ship
sumauc <- function(angle) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5])/2

  #for each weapon, calculate CDF(shipupperbound)
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    shipupperbound <- deg_to_arc(transformed_angle(angle,weapons[i,])+shipwidth/2)
    shiplowerbound <- deg_to_arc(transformed_angle(angle,weapons[i,])-shipwidth/2)
    #we have defined spread in degrees, so we must convert it to pixels to be consistent
    pxspread <- deg_to_arc(weapons[i,4])
   
    damage <- weapons[i,1]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(shipupperbound, error, pxspread) -
        hit_probability_coord_lessthan_x(shiplowerbound, error, pxspread)
    )
  }
 
  return(summed_auc)
}


#Now that we have all the pieces, we can perform the calculation.
#A special consideration is that we are using signed angles for the probability distritbutions, but
#in reality, the ship's guns wrap around, so we must devise a way to map
#-359 to 1, etc.

#So first, we define a vector from -360 to 360. These all possible angles of fire, since a gun can
#only have a position from -180 to 180 degrees on the ship, and can only track 360 degrees at most.

angles <- seq(-359,360)

#Then, we calculate the expected damage at each angle.
#Note that the R command sapply is equal to performing the function indicated by FUN
#at each index with the value at each index, returning a vector containing the output, so equal to a
#for loop.

dps_at_angles <- sapply(angles,FUN=sumauc)

#next, we go back to one loop of the ship by noting that
#-180 corresponds to +180
#-181 corresponds to +179 etc, so 360 + index for indices 1:180

for (i in 1:180) dps_at_angles[i+360] <- dps_at_angles[i+360]+dps_at_angles[i]

#and +360 corresponds to 0, +359 to -1 etc. so index - 360 for indices 540:720

for (i in 540:720) dps_at_angles[i-360] <- dps_at_angles[i-360]+dps_at_angles[i]

#finally, to get angles -179 to 180, we select indices 181 to 540 of the new vector.
dps_at_angles <- dps_at_angles[181:540]
#note that vector indices no longer correspond to angles, rather vector index 1 corresponds to -179.
#to get a correct plot add this

xaxis <- seq(-179,180)

#the problem is solved. Now we can find the optimum angle quite simply by selecting the midmost maximum
optimumangle <- xaxis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))[ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]

#plot the result
plot(dps_at_angles,x=xaxis)
abline(v=optimumangle)

#now to calculate the hit distributions that our weapons will have at this angle we will simply add
#the segment from the old angle to the new to the weapon's angle.

#we get the location of the weapon's mean as it tries to track the target from
#transformed_angle
#so

weaponadjustment_px <- function(weapon){
  angle_difference <- transformed_angle(optimumangle,weapon)
  arc <- angle_difference/360 * 2*pi*range
  return(arc)
}
#include our normal hit distribution function here


hit_distribution <- function(upperbounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upperbounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upperbounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upperbounds[j] >= 0) & (upperbounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upperbounds[1]+spread))/(2*spread))
      for (j in 2:(length(upperbounds))) vector[j] <- min(1,max(0,(upperbounds[j]+spread))/(2*spread)) - min(1,max(0,(upperbounds[j-1]+spread))/(2*spread))
      vector[length(upperbounds)+1] <- 1-min(1,max(0,(upperbounds[length(upperbounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upperbounds[1], standard_deviation, spread)
      for (j in 2:(length(upperbounds))) vector[j] <- (hit_probability_coord_lessthan_x(upperbounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upperbounds[j-1], standard_deviation, spread))
      vector[length(upperbounds)+1] <- (1-hit_probability_coord_lessthan_x(upperbounds[length(upperbounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upperbounds[1],0,standard_deviation)
      for (j in 2:(length(upperbounds))) vector[j] <- pnorm(upperbounds[j],0,standard_deviation) - pnorm(upperbounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upperbounds)+1] <- 1-pnorm(upperbounds[length(upperbounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#the usual - calculate ship cell upper bound angles
shipangle <- ship[5]/(2* pi *range)
cellangle <- shipangle/ship[6]
anglerangevector <- vector(mode="double", length = ship[6]+1)
anglerangevector[1] <- -shipangle/2
for (i in 1:(length(anglerangevector)-1)) anglerangevector[i+1] <- anglerangevector[i]+cellangle
#convert to pixels
pxupperboundvector <- anglerangevector*2*pi*range
pxupperboundvector
#now to get the hit distribution for weapons we do

hit_distribution_at_optimum_angle <- function(weapon){
  #convert spread to pixels
  pxspread <- deg_to_arc(weapon[,4])
  #adjust upper bound vector
  adj_ubs <- pxupperboundvector + weaponadjustment_px(weapon)
  return(hit_distribution(adj_ubs,error,pxspread))
}

#print results
hit_distribution_at_optimum_angle(weapons[1,])
hit_distribution_at_optimum_angle(weapons[2,])
hit_distribution_at_optimum_angle(weapons[3,])
hit_distribution_at_optimum_angle(weapons[4,])
hit_distribution_at_optimum_angle(weapons[5,])
hit_distribution_at_optimum_angle(weapons[6,])


Sample output:
Our weapons are

#for this test we will add two weapons that are slightly angled away from each other and able to overlap
#in the exact middle
weapon1 <- c(100, -10, 20, 5)
weapon2 <- c(100, 10, 20, 5)
# and some rearleft mounted point defense weapons to make sure the wraparound, extreme angles are correct
weapon3 <- c(30, -160, 20, 0)
weapon4 <- c(30, 180, 20, 0)
weapon5 <- c(30, 160, 20, 0)
weapon6 <- c(120, 90, 0, 5)
#lastly a weapon fixed at the ship front that has higher damage by itself, to test angle choice logic
#the correct choice should be to pick the overlap of the two weapons
noweapons <- 6


Our visual output is:
(https://i.ibb.co/8KxkZb2/image.png) (https://ibb.co/0D95bKZ)

Our printed output is

> #print results
> hit_distribution_at_optimum_angle(weapons[1,])
 [1] 0.118751068 0.077981550 0.102383867 0.121027732 0.129001152 0.124055991 0.107596198 0.084057103
 [9] 0.059030566 0.037172868 0.020934021 0.010514600 0.004698446 0.002794840
> hit_distribution_at_optimum_angle(weapons[2,])
 [1] 0.002794840 0.004698446 0.010514600 0.020934021 0.037172868 0.059030566 0.084057103 0.107596198
 [9] 0.124055991 0.129001152 0.121027732 0.102383867 0.077981550 0.118751068
> hit_distribution_at_optimum_angle(weapons[3,])
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
> hit_distribution_at_optimum_angle(weapons[4,])
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
> hit_distribution_at_optimum_angle(weapons[5,])
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
> hit_distribution_at_optimum_angle(weapons[6,])
 [1] 8.351879e-250 1.799421e-244 3.390000e-239 5.584509e-234 8.044357e-229 1.013261e-223 1.116033e-218
 [8] 1.074884e-213 9.052691e-209 6.666963e-204 4.293542e-199 2.417929e-194 1.190735e-189  1.000000e+00

(note: Hitting the first or last cell is a miss, so this means only weapons 1 and 2 can hit the target - as it should be)



Now here is a question: do we want to also model weapon lateral offset?

It should be quite easy with this code, just a matter of geometry (you project the coordinates of the enemy ship to the different circles for the upper bounds vector and the ship edges). But here is the problem: if we no longer assume our ship is pointlike, then it is no longer valid to imagine the enemy ship as a tangential line, either. See illustration. That means adding a depth to the armor matrix, to model hitting the side of the ship, which of course will mean a greater computational cost.

(https://i.ibb.co/Hd9JYwn/image.png) (https://ibb.co/164SKy8)
 
The question is, is the function worth the added computational cost, when in most cases we are probably fine with the assumption?

I will add that there should have been a picture of an explosion on top of the Dominator, but MSPaint doesn't support transparency so it gets to live for now.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Salter on December 20, 2022, 04:32:19 AM
My experience with missile conquest has largely been to give it a locust SRM to overwhelm/swarm a ship's point defense then follow it up with a harpoon/hurricane MIRV missiles. Point defense AI targets the closest thing to it and this gives you the opportunity to slip the real missiles past its defenses. Harpoons and MIRV's pack enough punch that they will inflict significant flux if they hit shields and inflict significant damage if they hit the ship, if nothing else.

Its a ship where having an officer with missile spec and all the related missile mod's installed makes it a top-tier pick in many situations. Even against remnant ordo's and the elite redacted enemies that hang around the coronal hypershunt.

Agreed. The Conquest is quite suitable for Remnant farming. If you field a fleet of only Conquests or Conquests with frigates, then I don't think enemy PD is really much of a problem. The sheer volume of incoming missiles will overwhelm point defenses. I should give Locusts a try in Remnant farming though, just to see if it does improve things. I would probably replace the Hurricane, so Squall-Locust, since this was also the best combination in testing.

Going a missile heavy fleet is not a terrible idea vs Rem's. Last fleet I ran nearly every ship had harpoons or reaper torpedo's. Mixed in two Harbingers and two Afflictors which is an extremely potent combo vs battlestations cause they will pop the stations shields and most missiles will make it through before the stations shields are back up again. You will need strong forward cruisers though to draw attention from the stations guns, or at least alot of smaller but tougher and faster ships. Omen's work great too as both escort ships and to EMP a target.

The more ships you have, the easier the fight gets really though casualties are inevitable and you might have to play at it a few times. The Conquest makes a great finisher ship, but you really need a fleet comp centered around EMP'ing shields and weapons off.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 20, 2022, 06:33:46 AM
Well, this fleet is working fine, admittedly the frigates do take losses and the station did tank for the first fights before being pounded to space dust.
(https://i.ibb.co/XtLDh3r/image.png) (https://ibb.co/zZxFy7T)
(https://i.ibb.co/fMMBDz8/image.png) (https://ibb.co/dBB1mwb)
(https://i.ibb.co/nm1df3p/image.png) (https://ibb.co/tK2jcZR)
(https://i.ibb.co/YT4Jq8L/image.png) (https://ibb.co/bRjY8LK)
(https://i.ibb.co/tHXWsFR/image.png) (https://ibb.co/YNZqkwg)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on December 20, 2022, 08:13:13 AM
It was never valid to model a ship as a tangential line. I always assumed that was just a simplifying assumption to easily make sure things work while we were developing code. Ships have weird geometry that results in corners and stuff, so regardless of what assumptions you have about the firing vessel, you're going to have to deal with non-trivial armor grids at some point.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 20, 2022, 09:34:52 AM
I mean there are degrees of valid. If the ship is pointlike then the inaccuracy is not in fact that the target ship corresponds to a line (at, say, range 1000, a difference of 10 px in range would increase our sd of 50 px to 50.5 px, not huge) but rather that even if you project the more complex structure of a ship to a line - which you might, it's essentially just find the right widths for hittable cells - then more complex rules than just adjacent in line might exist between those cells to pool damage.

To completely realistically model the target ship we would need the ship's exact shape, then trace the radii from each gun to see which cells are hittable, and have a map of how the ship's armor works to see which cells distribute damage to which other cells. This is highly ambitious.

A more limited solution is project the ship's shape to the arc of the circle defined by weapon and range. Then we still account for shape but not the relationship of cells to one another.

A still more limited solution is make the ship a box with two dimensions and use the solution above, in which case we do not need the ship's shape.

Finally there's this.

How deep we want to go depends again on our goals and what is valid is relative. For example, it is not in and of itself invalid to test a weapon against a slab of armor to see how it does vs. a tank, just so long as you are aware of your assumptions.

I'd like to hear what Liral thinks - Liral, since you are doing the heavy lifting wrt programming, let me know what you think is best, the mathematical model for all is doable. Programming might be non trivial though.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 20, 2022, 03:28:04 PM
Thanks for the comments and code reorganization.  My remaining requests for your next code release are to organize it not from beginning to end (as a proof would be) but from top to bottom level of abstraction as a directed acyclic graph would be, if each node could nest a directed acyclic graph with a single point of entry and single point of exit.  It is not only easier to translate but also to then call from other code, treating the whole program as one node on a larger graph, and so on, in an elegant hierarchical network of nodes and edges.

1. Put all 'driver' code that defines variables or calls functions in a main() function at the bottom of the program.
Code: Right
impress = function(argument) { ... }


procrastinate = function(otherArgument) { ... }


main = function() {
    argument = "professor"
    otherArgument = "writing a grant proposal"
    impress(argument)
    procrastinate(otherArgument)
}
Code: Wrong
impress = function(argument) { ... }


argument = "professor"
impress(argument)


procrastinate = function(otherArgument) { ... }


otherArgument = "writing a grant proposal"
procrastinate(otherArgument)

2. Global constants but no global variables.
Code: Right
SUBMISSION_DURATION = 20#minutes to drop off papers (ALL CAPS means constant)


isEnoughTimeLeft = function(assignmentDuration, currentTime, deadline) {
    return(deadline - assignmentDuration - SUBMISSION_DURATION > currentTime)
}


main = function() {
    assignmentDuration = 10#minutes
    currentTime = 180#3:00AM
    deadline = 540#9:00AM
    print(isEnoughTimeLeft(assignmentDuration, currentTime, deadline))
}
Code: Wrong
submission_duration = 20#minutes to drop off papers (not ALL CAPS means variable)


isEnoughTimeLeft = function(assignmentDuration, currentTime, deadline) {
    return(deadline - assignmentDuration - submission_duration > currentTime)
}


main = function() {
    assignmentDuration = 10#minutes
    currentTime = 180#3:00AM
    deadline = 540#9:00AM
    print(isEnoughTimeLeft(assignmentDuration, currentTime, deadline))
}
Code: Especially Wrong
distance = 2#kilometers from dorm to offices
speed = 0.1#kilometer per minute sleepy trudge in bad weather


submissionDuration = function(){
    return distance / speed
}


submission_duration = submissionDuration()#minutes to drop off papers


isEnoughTimeLeft = function(assignmentDuration, currentTime, deadline) {
    return(deadline - assignmentDuration - submission_duration > currentTime)
}


main = function() {
    assignmentDuration = 10#minutes
    currentTime = 180#3:00AM
    deadline = 540#9:00AM
    print(isEnoughTimeLeft(assignmentDuration, currentTime, deadline))
}

Also, please
- write names out rather than abbreviating them
- use the plural 'somethings' rather than 'vector' or 'something_vector'
- use two names for two things; e.g., spread_distance for distance and spread_angle for angle vs spread for both
- do not writemultiwordnameswithoutcamelcaseorunderscoresbecausetheyarehardtounderstandw henwrittenthus. :p

The results seem better this time, though I still can't make the full code work.  I hope you could look it over.

Code
Code
import math
from statistics import NormalDist
"""
Calculate the optimum angle to place the enemy ship relative to ours.

Assume:
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.
"""
def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of the normal distribution
    uniform_distribution_radius - radius of the uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
               
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    if b > 0: return (f(x, NormalDist(0, standard_deviation),
                        uniform_distribution_radius) if a > 0
                      else max(0, min(1, b / 2 + x)))
    return 0 if x < 0 else 1


def probability_hit_between_bounds(
        lower_bound: float,
        upper_bound: float,
        error_distance: float,
        spread_distance: float) -> float:
    """
    The probability to hit between the left and right edges of the
    ship is the integral, from the lower edge of the ship to the upper one,
    of the hit probability distribution. By the fundamental theorem of
    calculus, this integral equals
   
    CDF(upper edge) - CDF(lower edge).
    """
    return (probability_hit_before_bound(upper_bound, error_distance,
                                         spread_distance)
            - probability_hit_before_bound(lower_bound, error_distance,
                                           spread_distance))

"""
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.
"""
def arc_to_deg(arc: float, distance: float) -> float:
    """
    Return the degree angle of an arc.
    """
    return arc / distance * 360 / math.pi


def deg_to_arc(degrees: float, distance:  float) -> float:
    """
    Return the arc of a degree angle.
    """
    return degrees * distance / 360 * math.pi
   

def probability_hit_at_facing(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    upper_bound = deg_to_arc(weapon_facing + target_radius, distance)
    lower_bound = deg_to_arc(weapon_facing - target_radius, distance)
    return probability_hit_between_bounds(upper_bound, lower_bound,
                                          error_distance, spread_distance)

"""
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)
"""

def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def total_hit_probability(
        minimum_mean: float,
        maximum_mean: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    Return the total probability of hitting the target per second.
   
    Return the sum of areas under the probability distribution curve across
    the target, equivalent to integrating, from ship lower angle to ship
    greater angle, the probability distribution times damage for each
    weapon. We use the CDF we described above.
   
    We refer to auc (area under curve, ie. the curve of damage times
    probability distribution, giving total damage) for short.
   
    target_facing - target ship orientation
    Output: total damage to target ship
    """
    #we have defined spread in degrees, so we must convert it to
    #pixels to be consistent
   
    #angle of the ship's upper bound, in coordinates of the
    #distribution mean; note that this is weapon specific
    weapon_facing = transformed_angle(target_facing, minimum_mean, maximum_mean)
    return probability_hit_at_facing(weapon_facing, spread_distance,
                                     error_distance, target_facing,
                                     target_radius, distance)


def weapon_adjustment_distance(
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float) -> float:
    #Return the segment from the old angle plus the new angle plus
    #the weapon's angle.
   
    #the location of the weapon's mean as it tries to track the target from
    #transformed_angle
    angle_difference = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    return angle_difference / 360 * 2 * math.pi * distance


def hit_distribution(bounds, standard_deviation, spread):
    """Our normal hit distribution function."""
    if standard_deviation == 0:
        if spread == 0:
            #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
            #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
            #the case in the real game most likely
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[j] + spread) / double_spread)
                        - min(1, max(0, bounds[j-1] + spread) / double_spread)
                        for i, bound in enumerate(bounds[1:]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        #if spread is 0 but standard deviation is not 0 we have a normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                + tuple(cdf(bound) - cdf(bounds[i-1]) for i, bound in
                        enumerate(bounds[1:]))
                + (1 - cdf(bounds[-1])),)
    numbers = standard_deviation, spread
    return (probability_hit_within_bound(bounds[0], *numbers),
            + tuple(probability_hit_within_bound(bound, *numbers)
                    - probability_hit_within_bound(bounds[i-1], *numbers)
                    for i, bound in enumerate(bounds[1:]))
            + (1 - probability_hit_within_bound(bounds[-1], *numbers)),)
   

def hit_distribution_at_optimum_angle(
        spread_distance: float,
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        upper_bounds: tuple) -> tuple:
    adjustment_distance = weapon_adjustment_distance(weapon_facing,
                                                     minimum_mean,
                                                     maximum_mean,
                                                     distance)
    adjusted_upper_bounds = tuple(upper_bound + adjustment_distance for
                                  upper_bound in upper_bounds)
    return hit_distribution(adjusted_upper_bounds, error, spread_distance)


def main():
    #Section 1. general considerations
    #We will test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
   
    target_radius = 220 #(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    cell_count = 12
    distance = 1000
   
    #For testing, we will give these parameters for weapons:
    #damage, facing (deg), tracking range (deg), spread
    #collect them in a data frame
   
    #for this test we will add two weapons that are slightly angled
    #away from each other and able to overlap in the exact middle
    # and some rear mounted point defense weapons to make sure the
    #wraparound, extreme angles are correct
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
   
    for weapon in weapons:
        weapon["spread distance"] = deg_to_arc(weapon["spread"], distance)
        weapon["minimum_mean"] = (
            weapon["facing"] + (weapon["arc"] + weapon["spread distance"]) / 2)
        weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] - weapon["spread distance"]) / 2)

    #We will use the same parameter for the normal distribution as
    #we do in the other modules. Note that this results in the SD of
    #the normal distribution being in pixels.
   
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Now that we have all the pieces, we can perform the calculation.
    #A special consideration is that we are using signed angles for the
    #probability distritbutions, but in reality, the ship's guns wrap
    #around, so we must devise a way to map -359 to 1, etc.
   
    #So first, we define a vector from -360 to 360. These all possible
    #angles of fire, since a gun can only have a position from -180 to
    #180 degrees on the ship, and can only track 360 degrees at most.
    damage_per_second_total_at_angles = [
        sum(weapon["damage"]
            * total_hit_probability(weapon["minimum_mean"],
                                    weapon["maximum_mean"],
                                    weapon["spread distance"],
                                    error_distance,
                                    target_facing,
                                    target_radius,
                                    distance) for weapon in weapons)
        for target_facing in range(-359,361)]
   
    i = 0
    for target_facing, damage_per_second_total in zip(range(-359,360),
        damage_per_second_total_at_angles):
        print(i, target_facing, damage_per_second_total)
        i += 1
   
    #next, we go back to one loop of the ship by noting that
    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_total_at_angles[i+360] += (
            damage_per_second_total_at_angles[i])
   
    #and +360 corresponds to 0, +359 to -1 etc. so index - 360 for indices 540:720
    for i in range(540, 720): damage_per_second_total_at_angles[i-360] += (
        damage_per_second_total_at_angles[i])
   
    #finally, to get angles -179 to 180, we select indices 181 to 540 of the new vector.
    damage_per_second_total_at_angles = damage_per_second_total_at_angles[181:540]
    #note that vector indices no longer correspond to angles, rather vector index 1 corresponds to -179.
    #to get a correct plot add this
   
    xaxis = range(-179,180)
   
    #the problem is solved. Now we can find the optimum angle quite simply by selecting the midmost maximum
    #optimumangle = xaxis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))[ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    upper_bound_distances = [angle_range * 2 * math.pi * distance
                             for angle_range in angle_ranges]
    print(upper_bound_distances)
   
    #print results
    #for weapon in weapons: print(hit_distribution_at_optimum_angle(weapon))
main()
[close]
Result
0 -359 -58.45736069719945
1 -358 -59.369949169358
2 -357 -60.200197697714025
3 -356 -60.97017782938062
4 -355 -61.69793197100172
5 -354 -62.397492777221025
6 -353 -63.079284709317186
7 -352 -63.75072482594253
8 -351 -64.41687770529128
9 -350 -65.08106701768881
10 -349 -65.74539113596782
11 -348 -66.41112517767839
12 -347 -67.07901479604112
13 -346 -67.74947901771868
14 -345 -68.42274327167173
15 -344 -69.09892258032747
16 -343 -69.77807130504617
17 -342 -70.46021164938345
18 -341 -71.14534933453314
19 -340 -71.83348188450461
20 -339 -72.52460283929805
21 -338 -73.2187038180251
22 -337 -73.915775492474
23 -336 -74.61580803009397
24 -335 -75.31879128844636
25 -334 -76.02471489756628
26 -333 -76.73356829362154
27 -332 -77.44534073218146
28 -331 -78.16002129324735
29 -330 -78.87759888308253
30 -329 -79.59806223483477
31 -328 -80.3213999087391
32 -327 -81.04760029216706
33 -326 -81.77665159964289
34 -325 -82.50854187284878
35 -324 -83.24325898063478
36 -323 -83.98079061904369
37 -322 -84.72112431133871
38 -321 -85.46424740805138
39 -320 -86.21014708702822
40 -319 -86.95881035350219
41 -318 -87.71022404016581
42 -317 -88.4643748072612
43 -316 -89.22124914267398
44 -315 -89.98083336204925
45 -314 -90.74311360890879
46 -313 -91.50807585478374
47 -312 -92.27570589936325
48 -311 -93.04598937064402
49 -310 -93.81891172510204
50 -309 -94.59445824786832
51 -308 -95.37261405291996
52 -307 -96.1533640832804
53 -306 -96.93669311123327
54 -305 -97.72258573854823
55 -304 -98.51102639671251
56 -303 -99.30199934718382
57 -302 -100.09548868164558
58 -301 -100.89147832228139
59 -300 -101.68995202205245
60 -299 -102.49089336499591
61 -298 -103.29428576652893
62 -297 -104.10011247376839
63 -296 -104.90835656585223
64 -295 -105.71900095429191
65 -294 -106.53202838331481
66 -293 -107.34742143023345
67 -292 -108.16516250582131
68 -291 -108.98523385469647
69 -290 -109.8076175557257
70 -289 -110.6322955224343
71 -288 -111.459249503426
72 -287 -112.2884610828195
73 -286 -113.11991168069714
74 -285 -113.95358255355853
75 -284 -114.78945479479384
76 -283 -115.62750933516183
77 -282 -116.46772694328584
78 -281 -117.31008822616023
79 -280 -118.15457362966131
80 -279 -119.00116343908174
81 -278 -119.84983777966727
82 -277 -120.70057661717132
83 -276 -121.55335975841447
84 -275 -122.40816685186486
85 -274 -123.2649773882205
86 -273 -124.12377070101286
87 -272 -124.98452596721174
88 -271 -125.8472222078524
89 -270 -126.71183828866641
90 -269 -127.57835292072713
91 -268 -128.446744661108
92 -267 -129.31699191355037
93 -266 -130.18907292914292
94 -265 -131.06296580701405
95 -264 -131.93864849503768
96 -263 -132.81609879054324
97 -262 -133.6952943410456
98 -261 -134.5762126449803
99 -260 -135.45883105245719
100 -259 -136.34312676601658
101 -258 -137.22907684140225
102 -257 -138.1166581883493
103 -256 -139.00584757137256
104 -255 -139.89662161057836
105 -254 -140.78895678247764
106 -253 -141.6828294208199
107 -252 -142.5782157174279
108 -251 -143.47509172305405
109 -250 -144.37343334823848
110 -249 -145.27321636418537
111 -248 -146.1744164036494
112 -247 -147.07700896182453
113 -246 -147.98096939726236
114 -245 -148.88627293277833
115 -244 -149.7928946563884
116 -243 -150.7008095222451
117 -242 -151.60999235159187
118 -241 -152.5204178337192
119 -240 -153.43206052694512
120 -239 -154.344894859588
121 -238 -155.2588951309698
122 -237 -156.17403551241628
123 -236 -157.0902900482721
124 -235 -158.007632656928
125 -234 -158.92603713185605
126 -233 -159.84547714265724
127 -232 -160.7659262361189
128 -231 -161.68735783728175
129 -230 -162.6097452505166
130 -229 -163.53306166061626
131 -228 -164.45728013389117
132 -227 -165.38237361927887
133 -226 -166.3083149494608
134 -225 -167.23507684199754
135 -224 -168.162631900459
136 -223 -169.09095261557832
137 -222 -170.02001136641212
138 -221 -170.94978042150268
139 -220 -171.88023194006425
140 -219 -172.81133797316508
141 -218 -173.74307046492513
142 -217 -174.67540125372966
143 -216 -175.6083020734377
144 -215 -176.54174455461347
145 -214 -177.47570022576
146 -213 -178.4101405145656
147 -212 -179.3450367491547
148 -211 -180.28036015935646
149 -210 -181.2160818779714
150 -209 -182.15217294205715
151 -208 -183.08860429421733
152 -207 -184.02534678389958
153 -206 -184.9623711687059
154 -205 -185.89964811570957
155 -204 -186.83714820277822
156 -203 -187.77484191991044
157 -202 -188.71269967057765
158 -201 -189.65069177307478
159 -200 -190.58878846188088
160 -199 -191.52695988902494
161 -198 -192.46517612546444
162 -197 -193.4034071624682
163 -196 -194.3416229130076
164 -195 -195.27979321315752
165 -194 -196.21788782350583
166 -193 -197.15587643056745
167 -192 -198.09372864820662
168 -191 -199.03141401907124
169 -190 -199.9689020160291
170 -189 -200.90616204361555
171 -188 -201.8431634394852
172 -187 -202.77987547587483
173 -186 -203.71626736107004
174 -185 -204.65230824088053
175 -184 -205.5879672001215
176 -183 -206.52321326410356
177 -182 -207.45801540012926
178 -181 -208.39234251899381
179 -180 -209.32616347649386
180 -179 -210.25944707494688
181 -178 -211.1921620647103
182 -177 -212.1242771457081
183 -176 -213.05576096897218
184 -175 -213.98658213817538
185 -174 -214.9167092111859
186 -173 -215.84611070161384
187 -172 -216.77475508037523
188 -171 -217.7026107772519
189 -170 -218.62964618246718
190 -169 -219.55582964825598
191 -168 -220.4811294904512
192 -167 -221.40551399006725
193 -166 -222.32895139489335
194 -165 -223.2514099210904
195 -164 -224.1728577547926
196 -163 -225.09326305371442
197 -162 -226.01259394876467
198 -161 -226.9308185456617
199 -160 -227.8479049265531
200 -159 -228.76382115164574
201 -158 -229.67853526083272
202 -157 -230.59201527532798
203 -156 -231.50422919930892
204 -155 -232.41514502155488
205 -154 -233.32473071709532
206 -153 -234.2329542488634
207 -152 -235.13978356934743
208 -151 -236.04518662224956
209 -150 -236.9491313441512
210 -149 -237.85158566617193
211 -148 -238.75251751564429
212 -147 -239.6518948177836
213 -146 -240.5496854973605
214 -145 -241.44585748038142
215 -144 -242.34037869577122
216 -143 -243.23321707705148
217 -142 -244.12434056403214
218 -141 -245.0137171044976
219 -140 -245.90131465589857
220 -139 -246.78710118704538
221 -138 -247.67104467980528
222 -137 -248.55311313079756
223 -136 -249.43327455309586
224 -135 -250.31149697792904
225 -134 -251.18774845638532
226 -133 -252.06199706111573
227 -132 -252.934210888042
228 -131 -253.8043580580638
229 -130 -254.6724067187691
230 -129 -255.53832504614314
231 -128 -256.4020812462807
232 -127 -257.2636435570989
233 -126 -258.12298025005003
234 -125 -258.98005963183226
235 -124 -259.8348500461096
236 -123 -260.6873198752245
237 -122 -261.5374375419091
238 -121 -262.38517151100757
239 -120 -263.2304902911843
240 -119 -264.0733624366455
241 -118 -264.9137565488511
242 -117 -265.7516412782292
243 -116 -266.58698532589324
244 -115 -267.4197574453538
245 -114 -268.24992644423236
246 -113 -269.07746118597527
247 -112 -269.90233059156435
248 -111 -270.72450364122614
249 -110 -271.54394937614586
250 -109 -272.3606369001715
251 -108 -273.17453538152296
252 -107 -273.98561405449686
253 -106 -274.793842221171
254 -105 -275.5991892531067
255 -104 -276.4016245930478
256 -103 -277.2011177566212
257 -102 -277.99763833403154
258 -101 -278.7911559917558
259 -100 -279.58164047423514
260 -99 -280.36906160556373
261 -98 -281.15338929117655
262 -97 -281.9345935195331
263 -96 -282.7126443637979
264 -95 -283.48751198351863
265 -94 -284.2591666263037
266 -93 -285.02757862949204
267 -92 -285.7927184218238
268 -91 -286.5545565251076
269 -90 -287.31306355589055
270 -89 -288.0682102271426
271 -88 -288.8199673499848
272 -87 -289.5683058355769
273 -86 -290.3131966974334
274 -85 -291.05461105494965
275 -84 -291.7925201401383
276 -83 -292.52689531260904
277 -82 -293.2577080949452
278 -81 -293.9849302567924
279 -80 -294.7085340110321
280 -79 -295.42849245850766
281 -78 -296.1447805633294
282 -77 -296.8573772177405
283 -76 -297.5662694570394
284 -75 -298.27146074652353
285 -74 -298.97298665868675
286 -73 -299.6709433782487
287 -72 -300.3655374493967
288 -71 -301.05716896872656
289 -70 -301.74656461589177
290 -69 -302.43498049318185
291 -68 -303.1244959183351
292 -67 -303.81841546878246
293 -66 -304.52178459448515
294 -65 -305.24200119420374
295 -64 -305.98947054457227
296 -63 -306.7782061059833
297 -62 -307.62623111678045
298 -61 -308.5555980660002
299 -60 -309.5918317361554
300 -59 -310.7626335113653
301 -58 -312.0957715008698
302 -57 -313.6162220378177
303 -56 -315.3428055146567
304 -55 -317.28473798479627
305 -54 -319.43865243779976
306 -53 -321.7866833366129
307 -52 -324.2961236236918
308 -51 -326.92095276055716
309 -50 -329.60523051294604
310 -49 -332.28801748966595
311 -48 -334.9091975444911
312 -47 -337.41540842931806
313 -46 -339.7652751056342
314 -45 -341.933282073403
315 -44 -343.91187314163335
316 -43 -345.71165947979495
317 -42 -347.35987721042983
318 -41 -348.89741239740624
319 -40 -350.3747872088267
320 -39 -351.84749540248197
321 -38 -353.3710309095495
322 -37 -354.9959164182856
323 -36 -356.76303885944697
324 -35 -358.69963556915485
325 -34 -360.81631927708236
326 -33 -363.10553569898116
327 -32 -365.5417715879919
328 -31 -368.0836545050916
329 -30 -370.67782514784125
330 -29 -373.2641706616861
331 -28 -375.7817552929423
332 -27 -378.1746427904863
333 -26 -380.3968169444016
334 -25 -382.4155753708854
335 -24 -384.2130575515565
336 -23 -385.7859018393075
337 -22 -387.143329995243
338 -21 -388.30416846356104
339 -20 -389.2933999728871
340 -19 -390.1387993713174
341 -18 -390.8680751248394
342 -17 -391.50675944046475
343 -16 -392.07691257395294
344 -15 -392.5965658721769
345 -14 -393.0797412454191
346 -13 -393.53685276111116
347 -12 -393.9753074492379
348 -11 -394.40016023091937
349 -10 -394.81472549416185
350 -9 -395.2210927061679
351 -8 -395.6205284570263
352 -7 -396.0137702519554
353 -6 -396.4012293503031
354 -5 -396.78312379557406
355 -4 -397.1595616077663
356 -3 -397.53059052995707
357 -2 -397.89622653268134
358 -1 -398.2564694904493
359 0 -398.6113114679912
360 1 -398.96074093447305
361 2 -399.3047448276244
362 3 -399.643309528301
363 4 -399.9764213044391
364 5 -400.3040665064467
365 6 -400.62623165049035
366 7 -400.9429034530526
367 8 -401.25406884507055
368 9 -401.55971497782207
369 10 -401.85982922557884
370 11 -402.1543991870327
371 12 -402.44341268627477
372 13 -402.72685777360033
373 14 -403.00472272625564
374 15 -403.2769960491439
375 16 -403.54366647552115
376 17 -403.80472296767334
377 18 -404.06015471757644
378 19 -404.30995114755046
379 20 -404.55410191089175
380 21 -404.7925968924944
381 22 -405.0254262094589
382 23 -405.2525802116901
383 24 -405.4740494824693
384 25 -405.6898248390279
385 26 -405.89989733309756
386 27 -406.1042582514454
387 28 -406.302899116401
388 29 -406.4958116863619
389 30 -406.68298795628556
390 31 -406.86442015813816
391 32 -407.04010076129475
392 33 -407.2100224727528
393 34 -407.37417823690123
394 35 -407.5325612340615
395 36 -407.68516487580115
396 37 -407.83198279198365
397 38 -407.9730087973988
398 39 -408.10823680967184
399 40 -408.2376606550592
400 41 -408.3612736256739
401 42 -408.47906750611907
402 43 -408.59103051053205
403 44 -408.6971430695585
404 45 -408.797369545281
405 46 -408.89164255588275
406 47 -408.97983447243803
407 48 -409.06170767350454
408 49 -409.1368313539642
409 50 -409.20444849615876
410 51 -409.26327303208905
411 52 -409.31119605241116
412 53 -409.3448837641385
413 54 -409.35926188015844
414 55 -409.3469040465198
415 56 -409.29737692010485
416 57 -409.1966393777124
417 58 -409.02664095721696
418 59 -408.7653024688745
419 60 -408.38707314865053
420 61 -407.86422679474225
421 62 -407.16897261927227
422 63 -406.2763158142576
423 64 -405.1674259310149
424 65 -403.8330935652938
425 66 -402.2767247591195
426 67 -400.5162849684169
427 68 -398.5846918039269
428 69 -396.52837018437947
429 70 -394.4039915817457
430 71 -392.27375632169577
431 72 -390.19986497417733
432 73 -388.2389897439809
433 74 -386.43755677114484
434 75 -384.82848537613944
435 76 -383.42974321294275
436 77 -382.24473900981627
437 78 -381.26426653676344
438 79 -380.4694990071914
439 80 -379.8354457628118
440 81 -379.33432065280556
441 82 -378.9384025995037
442 83 -378.622146449773
443 84 -378.3634791112029
444 85 -378.14435670506316
445 86 -377.9507451772072
446 87 -377.77221873881456
447 88 -377.6013590750034
448 89 -377.43310042203507
449 90 -377.2641179940685
450 91 -377.09231237214397
451 92 -376.91640746130616
452 93 -376.7356566990117
453 94 -376.5496402166749
454 95 -376.35813181015686
455 96 -376.1610157479243
456 97 -375.958237024904
457 98 -375.7497728585449
458 99 -375.5356170126942
459 100 -375.3157715117283
460 101 -375.0902424266894
461 102 -374.8590378114777
462 103 -374.6221667285759
463 104 -374.37963880537643
464 105 -374.13146403901885
465 106 -373.87765271332535
466 107 -373.6182153644306
467 108 -373.35316276679384
468 109 -373.0825059274625
469 110 -372.8062560835172
470 111 -372.52442470072594
471 112 -372.2370234726183
472 113 -371.9440643197114
473 114 -371.6455593887588
474 115 -371.3415210520185
475 116 -371.03196190650635
476 117 -370.71689477323093
477 118 -370.39633269643656
478 119 -370.0702889428044
479 120 -369.7387770006675
480 121 -369.4018105791926
481 122 -369.0594036075667
482 123 -368.71157023415196
483 124 -368.3583248256431
484 125 -367.99968196620546
485 126 -367.6356564565987
486 127 -367.2662633132913
487 128 -366.8915177675632
488 129 -366.5114352645921
489 130 -366.12603146252786
490 131 -365.7353222315596
491 132 -365.33932365296283
492 133 -364.9380520181453
493 134 -364.53152382766245
494 135 -364.11975579023965
495 136 -363.70276482177684
496 137 -363.28056804433027
497 138 -362.85318278510056
498 139 -362.4206265753949
499 140 -361.9829171495787
500 141 -361.54007244403164
501 142 -361.0921105960647
502 143 -360.6390499428471
503 144 -360.18090902031895
504 145 -359.7177065620866
505 146 -359.2494614983036
506 147 -358.7761929545564
507 148 -358.29792025071674
508 149 -357.81466289980926
509 150 -357.32644060684163
510 151 -356.8332732676394
511 152 -356.3351809676727
512 153 -355.83218398086035
513 154 -355.32430276837033
514 155 -354.8115579774132
515 156 -354.29397044001394
516 157 -353.77156117179067
517 158 -353.2443513707037
518 159 -352.71236241580846
519 160 -352.1756158659938
520 161 -351.6341334587111
521 162 -351.08793710868696
522 163 -350.53704890664415
523 164 -349.9814911179917
524 165 -349.42128618151344
525 166 -348.8564567080596
526 167 -348.28702547920574
527 168 -347.71301544591745
528 169 -347.1344497272148
529 170 -346.5513516087981
530 171 -345.96374454169677
531 172 -345.37165214088856
532 173 -344.7750981839195
533 174 -344.17410660950924
534 175 -343.5687015161603
535 176 -342.958907160738
536 177 -342.34474795706444
537 178 -341.7262484744866
538 179 -341.10343343644536
539 180 -340.4763277190407
540 181 -339.84495634957534
541 182 -339.20934450509844
542 183 -338.5695175109528
543 184 -337.92550083928904
544 185 -337.2773201075922
545 186 -336.62500107720405
546 187 -335.9685696518112
547 188 -335.3080518759632
548 189 -334.6434739335536
549 190 -333.974862146309
550 191 -333.30224297226516
551 192 -332.625643004247
552 193 -331.945088968322
553 194 -331.2606077222687
554 195 -330.5722262540278
555 196 -329.8799716801441
556 197 -329.18387124421236
557 198 -328.48395231530867
558 199 -327.7802423864178
559 200 -327.07276907286035
560 201 -326.3615601107075
561 202 -325.6466433551894
562 203 -324.9280467791087
563 204 -324.2057984712372
564 205 -323.47992663470467
565 206 -322.75045958540545
566 207 -322.01742575036974
567 208 -321.28085366615153
568 209 -320.54077197720164
569 210 -319.7972094342457
570 211 -319.05019489264146
571 212 -318.29975731074944
572 213 -317.5459257482846
573 214 -316.78872936467724
574 215 -316.02819741742525
575 216 -315.2643592604283
576 217 -314.49724434234395
577 218 -313.72688220492125
578 219 -312.9533024813327
579 220 -312.17653489451345
580 221 -311.3966092554844
581 222 -310.6135554616837
582 223 -309.82740349528876
583 224 -309.0381834215261
584 225 -308.2459253870011
585 226 -307.45065961800304
586 227 -306.6524164188296
587 228 -305.8512261700755
588 229 -305.0471193269631
589 230 -304.2401264176268
590 231 -303.430278041432
591 232 -302.6176048672646
592 233 -301.80213763183053
593 234 -300.98390713796135
594 235 -300.16294425290823
595 236 -299.3392799066263
596 237 -298.51294509007374
597 238 -297.6839708535066
598 239 -296.85238830476476
599 240 -296.0182286075673
600 241 -295.1815229797917
601 242 -294.3423026917661
602 243 -293.5005990645651
603 244 -292.6564434682763
604 245 -291.8098673203093
605 246 -290.9609020836696
606 247 -290.1095792652395
607 248 -289.2559304140815
608 249 -288.3999871197082
609 250 -287.5417810103746
610 251 -286.6813437513602
611 252 -285.81870704326684
612 253 -284.9539026202973
613 254 -284.08696224854066
614 255 -283.2179177242736
615 256 -282.3468008722392
616 257 -281.4736435439452
617 258 -280.59847761594335
618 259 -279.7213349881438
619 260 -278.84224758209507
620 261 -277.9612473392815
621 262 -277.0783662194254
622 263 -276.1936361987861
623 264 -275.30708926845676
624 265 -274.4187574326769
625 266 -273.5286727071334
626 267 -272.6368671172587
627 268 -271.7433726965652
628 269 -270.8482214849314
629 270 -269.95144552693205
630 271 -269.05307687015363
631 272 -268.15314756351404
632 273 -267.25168965558845
633 274 -266.3487351929318
634 275 -265.44431621840613
635 276 -264.53846476952646
636 277 -263.6312128767871
637 278 -262.7225925619932
638 279 -261.81263583662377
639 280 -260.90137470016606
640 281 -259.9888411384684
641 282 -259.075067122096
642 283 -258.1600846046838
643 284 -257.24392552130973
644 285 -256.3266217868565
645 286 -255.4082052943786
646 287 -254.48870791348355
647 288 -253.56816148871786
648 289 -252.6465978379316
649 290 -251.7240487506918
650 291 -250.80054598666226
651 292 -249.876121274005
652 293 -248.95080630779265
653 294 -248.02463274841486
654 295 -247.0976322199794
655 296 -246.16983630875313
656 297 -245.2412765615797
657 298 -244.31198448430177
658 299 -243.3819915402164
659 300 -242.4513291485032
660 301 -241.5200286826793
661 302 -240.588121469056
662 303 -239.65563878518424
663 304 -238.72261185834395
664 305 -237.78907186400846
665 306 -236.85504992431584
666 307 -235.92057710656132
667 308 -234.98568442170222
668 309 -234.05040282282633
669 310 -233.11476320369178
670 311 -232.17879639721787
671 312 -231.2425331740081
672 313 -230.30600424089272
673 314 -229.36924023943376
674 315 -228.43227174449714
675 316 -227.49512926277623
676 317 -226.55784323136112
677 318 -225.6204440162947
678 319 -224.68296191115226
679 320 -223.745427135611
680 321 -222.80786983403442
681 322 -221.87032007408234
682 323 -220.9328078452852
683 324 -219.99536305768237
684 325 -219.05801554042074
685 326 -218.1207950403864
686 327 -217.1837312208367
687 328 -216.24685366005008
688 329 -215.3101918499689
689 330 -214.37377519485622
690 331 -213.43763300997392
691 332 -212.50179452024676
692 333 -211.56628885895913
693 334 -210.63114506643296
694 335 -209.6963920887531
695 336 -208.76205877645714
696 337 -207.8281738832593
697 338 -206.89476606479673
698 339 -205.961863877342
699 340 -205.02949577657353
700 341 -204.09769011631573
701 342 -203.16647514731926
702 343 -202.2358790160244
703 344 -201.30592976335333
704 345 -200.37665532349723
705 346 -199.44808352273344
706 347 -198.5202420782262
707 348 -197.5931585968528
708 349 -196.66686057403774
709 350 -195.7413753925822
710 351 -194.81673032150047
711 352 -193.8929525147925
712 353 -192.97006901009956
713 354 -192.0481067268994
714 355 -191.12709246352847
715 356 -190.20705289097697
716 357 -189.28801453846506
717 358 -188.3700037586132
718 359 -187.45304664391338
[-110.0, -91.66666666666666, -73.33333333333333, -54.99999999999999, -36.66666666666665, -18.333333333333318, 1.634938358441546e-14, 18.33333333333335, 36.666666666666686, 55.00000000000002, 73.33333333333336, 91.6666666666667, 110.00000000000003]
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 20, 2022, 10:08:38 PM
Can do about the code! Also, let me just say I appreciate the examples I can relate to.

As for your output: well, it certainly still doesn't have the correct spectrum/periodicity. That makes me think there is a problem with the deg to arc or arc to deg conversions somewhere, either thar or something fundamentally wrong with the probability functions.

I wonder if all of these are in the same unit here?


for weapon in weapons:
        weapon["spread distance"] = deg_to_arc(weapon["spread"], distance)
        weapon["minimum_mean"] = (
            weapon["facing"] + (weapon["arc"] + weapon["spread distance"]) / 2)
        weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] - weapon["spread distance"]) / 2)


It will be helpful if you can rig the functions to produce some prints of what they do with the input and also maybe print the list of min/max means. Also it will be very helpful for any later debugging if you make it print out a graph, since we want spikes at certain specific coordinates that are easy to grasp visually.

Here is what the spikes should look like before the cut and paste operation at the end. Note that the thing that we do with the angles is equivalent to printing this out, cutting at the lines, then pasting the left edge of the right piece to the left edge and the right edge of the left piece to the right edge, and summing the graphs that are now on top of each other.

(https://i.ibb.co/Snmrsdr/image.png) (https://ibb.co/2tMNnsN)

This type of plot is also helpful because we know the spikes should be smooth other than at the point, and symmetrical before the cut and paste operation, so if that is not the case there is a problem somewhere. Also, they should have flat tops for all ranges where all weapons that can fire on the target can also track the target for some distance. In this example we get spikes for the overlap of weapons because they are so spaced that there is only one point where adjacent weapons can track the target, but a flat top for the one point defense gun before the summation happens (and two spikes for the pd guns after the summation, because there are two singular points where two of them are aimed at the target).

By the way, for any fans of graphs, here is a plot of dps per angle for 6 guns placed at 60 degree angles dealing 10 damage with 5 spread, as their tracking ability goes from 0 to 180 in increments of 10. Color denotes the tracking arc.


#let's make a pretty graph
matrix <- matrix(data=0, ncol = 3, nrow = 19*360)
for (j in seq(0,180,10)){
  weapon1 <- c(10, 0, j, 5)
  weapon2 <- c(10, 60, j, 5)
  weapon3 <- c(10, -60, j, 5)
  weapon4 <- c(10, 120, j, 5)
  weapon5 <- c(10, -120, j, 5)
  weapon6 <- c(10, 180, j, 5)
  noweapons <- 6
  weapons <- data.frame()
  for (i in 1:noweapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
  colnames(weapons) <- c("damage","facing","trackingrange","spread")
  weapons <- cbind(weapons, maxmean=(weapons$facing+weapons$trackingrange/2-weapons$spread/2))
  weapons <- cbind(weapons, minmean=(weapons$facing-weapons$trackingrange/2+weapons$spread/2))
 
  angles <- seq(-359,360)
  dps_at_angles <- sapply(angles,FUN=sumauc)
 
 
  for (i in 1:180) dps_at_angles[i+360] <- dps_at_angles[i+360]+dps_at_angles
  for (i in 540:720) dps_at_angles[i-360] <- dps_at_angles[i-360]+dps_at_angles
  dps_at_angles <- dps_at_angles[181:540]
  for (i in 1:360) {
    matrix[(1+j*36):(360+j*36),1] <- dps_at_angles
    matrix[(1+j*36):(360+j*36),2] <- seq(1:360)
    matrix[(1+j*36):(360+j*36),3] <- j
  }
}
plot(y=matrix[,1],x=matrix[,2]-180,col=matrix[,3],pch=20,xlab="angle",ylab="dps")


(https://i.ibb.co/100n3M7/image.png) (https://ibb.co/2FF6wSW)

Same, but the guns are placed at 15, -15, 30, -30, 45, -45 instead

(https://i.ibb.co/vJybjmL/image.png) (https://ibb.co/Hrw0HYG)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 21, 2022, 10:46:01 AM
Can do about the code! Also, let me just say I appreciate the examples I can relate to.

Good!

Quote
As for your output: well, it certainly still doesn't have the correct spectrum/periodicity. That makes me think there is a problem with the deg to arc or arc to deg conversions somewhere, either thar or something fundamentally wrong with the probability functions.

I checked the arc_measure and arc_length and found that I had implemented them incorrectly. 

Quote
I wonder if all of these are in the same unit here?


for weapon in weapons:
        weapon["spread distance"] = deg_to_arc(weapon["spread"], distance)
        weapon["minimum_mean"] = (
            weapon["facing"] + (weapon["arc"] + weapon["spread distance"]) / 2)
        weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] - weapon["spread distance"]) / 2)


These numbers are not all of the same unit: spread distance is a distance whereas the minimum and maximum means are angles.

Code
Code: Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
               
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    if b > 0: return (f(x, NormalDist(0, standard_deviation),
                        uniform_distribution_radius) if a > 0
                      else max(0, min(1, b / 2 + x)))
    return 0 if x < 0 else 1


def probability_hit_between_bounds(
        lower_bound: float,
        upper_bound: float,
        error_distance: float,
        spread_distance: float) -> float:
    """
    The probability to hit between the left and right edges of the
    ship is the integral, from the lower edge of the ship to the
    upper one, of the hit probability distribution. By the
    fundamental theorem of calculus, this integral equals
   
    CDF(upper edge) - CDF(lower edge).
   
    lower_bound -
    upper_bound -
    error_distance -
    spread_distance -
    """
    return (probability_hit_before_bound(upper_bound, error_distance,
                                         spread_distance)
            - probability_hit_before_bound(lower_bound, error_distance,
                                           spread_distance))


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius
   

def probability_hit_at_facing(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    weapon_facing -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    """
    lower_bound = arc_length(weapon_facing, distance) + target_radius
    upper_bound = arc_length(weapon_facing, distance) - target_radius
    return probability_hit_between_bounds(upper_bound, lower_bound,
                                          error_distance, spread_distance)


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def total_hit_probability(
        minimum_mean: float,
        maximum_mean: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    Return the total probability of hitting the target per second.
   
    This probability equals the integral, from ship lower angle to
    ship greater angle, the probability distribution times damage
    for each weapon.
   
    minimum_mean -
    maximum_mean -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    target_facing - target ship orientation
    """
    #we have defined spread in degrees, so we must convert it to
    #pixels to be consistent
   
    #angle of the ship's upper bound, in coordinates of the
    #distribution mean; note that this is weapon specific
    weapon_facing = transformed_angle(target_facing, minimum_mean, maximum_mean)
    return probability_hit_at_facing(weapon_facing, spread_distance,
                                     error_distance, target_facing,
                                     target_radius, distance)


def weapon_adjustment_distance(
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float) -> float:
    """
   
    weapon_facing -
    minimum_mean -
    maximum_mean -
    distance -
    """
    #segment from the old angle plus the new angle plus the weapon's
    #angle.
    angle_difference = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    #location of the weapon's mean as it tries to track the target
    #from transformed_angle
    return angle_difference / 180 * math.pi * distance


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Hit distribution function.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[j] + spread) / double_spread)
                        - min(1, max(0, bounds[j-1] + spread) / double_spread)
                        for i, bound in enumerate(bounds[1:]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                + tuple(cdf(bound) - cdf(bounds[i-1]) for i, bound in
                        enumerate(bounds[1:]))
                + (1 - cdf(bounds[-1])),)
    numbers = standard_deviation, spread
    return (probability_hit_within_bound(bounds[0], *numbers),
            + tuple(probability_hit_within_bound(bound, *numbers)
                    - probability_hit_within_bound(bounds[i-1], *numbers)
                    for i, bound in enumerate(bounds[1:]))
            + (1 - probability_hit_within_bound(bounds[-1], *numbers)),)
   

def hit_distribution_at_optimum_angle(
        spread_distance: float,
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        upper_bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_distance = weapon_adjustment_distance(weapon_facing,
                                                     minimum_mean,
                                                     maximum_mean,
                                                     distance)
    adjusted_upper_bounds = tuple(upper_bound + adjustment_distance for
                                  upper_bound in upper_bounds)
    return hit_distribution(adjusted_upper_bounds, error, spread_distance)


def main():
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
   
    target_radius = 220 #(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    cell_count = 12
    distance = 1000
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        print("spread angle", weapon["spread"])
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        print("spread distance", weapon["spread distance"])
        weapon["minimum_mean"] = (
            weapon["facing"] + (weapon["arc"] + weapon["spread"]) / 2)
        print("minimum mean", weapon["minimum_mean"])
        weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] - weapon["spread"]) / 2)
        print("maximum mean", weapon["maximum_mean"])
        print()
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_total_at_angles = [
        sum(weapon["damage"]
            * total_hit_probability(weapon["minimum_mean"],
                                    weapon["maximum_mean"],
                                    weapon["spread distance"],
                                    error_distance,
                                    target_facing,
                                    target_radius,
                                    distance)
            for weapon in weapons)
        for target_facing in range(-359,361)]
   
    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_total_at_angles[i+360] += (
            damage_per_second_total_at_angles[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720): damage_per_second_total_at_angles[i-360] += (
        damage_per_second_total_at_angles[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_total_at_angles = damage_per_second_total_at_angles[181:540]
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
    xaxis = range(-179,180)
   
    #the optimum angle is the midmost maximum
    #optimumangle = xaxis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))[ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    upper_bound_distances = [angle_range * 2 * math.pi * distance
                             for angle_range in angle_ranges]
    print("upper bound distances")
    for upper_bound_distance in upper_bound_distances:
        print(round(upper_bound_distance, 3))
   
    #print results
    #for weapon in weapons: print(hit_distribution_at_optimum_angle(weapon))
main()
[close]
spread angle 5
spread distance 87.26646259971648
minimum mean 2.5
maximum mean -2.5

spread angle 5
spread distance 87.26646259971648
minimum mean 22.5
maximum mean 17.5

spread angle 0
spread distance 0.0
minimum mean -150.0
maximum mean -150.0

spread angle 0
spread distance 0.0
minimum mean 190.0
maximum mean 190.0

spread angle 0
spread distance 0.0
minimum mean 170.0
maximum mean 170.0

spread angle 5
spread distance 87.26646259971648
minimum mean 92.5
maximum mean 87.5

upper bound distances
-110.0
-91.667
-73.333
-55.0
-36.667
-18.333
0.0
18.333
36.667
55.0
73.333
91.667
110.0
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 21, 2022, 11:06:20 AM
Right. And if you look at my code

weapons <- cbind(weapons, maxmean=(weapons$facing+weapons$trackingrange/2-weapons$spread/2))
weapons <- cbind(weapons, minmean=(weapons$facing-weapons$trackingrange/2+weapons$spread/2))



These are all angles. Need to be careful with these sums - all elements must be same unit.

I think there's also another error in that the minimum mean should be facing minus tracking angle/2 minus spread/2, not facing plus tracking angle/2 minus spread/2. This would explain the negative numbers I think, and is why your minimum means are higher than maximum even in the new code and output.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 21, 2022, 01:28:35 PM
Right. And if you look at my code

weapons <- cbind(weapons, maxmean=(weapons$facing+weapons$trackingrange/2-weapons$spread/2))
weapons <- cbind(weapons, minmean=(weapons$facing-weapons$trackingrange/2+weapons$spread/2))



These are all angles. Need to be careful with these sums - all elements must be same unit.

Now I'm a little confused.  I have a degree angle number in each weapon called "spread" alongside an arc length number called "spread distance", and the minimum and maximum means are angles based on the former and two other angles.

Quote
I think there's also another error in that the minimum mean should be facing minus tracking angle/2 minus spread/2, not facing plus tracking angle/2 minus spread/2. This would explain the negative numbers I think, and is why your minimum means are higher than maximum even in the new code and output.

Ok, fixed that, but it still seems off.  :-[

Code
Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
               
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    if b > 0: return (f(x, NormalDist(0, standard_deviation),
                        uniform_distribution_radius) if a > 0
                      else max(0, min(1, b / 2 + x)))
    return 0 if x < 0 else 1


def probability_hit_between_bounds(
        lower_bound: float,
        upper_bound: float,
        error_distance: float,
        spread_distance: float) -> float:
    """
    The probability to hit between the left and right edges of the
    ship is the integral, from the lower edge of the ship to the
    upper one, of the hit probability distribution. By the
    fundamental theorem of calculus, this integral equals
   
    CDF(upper edge) - CDF(lower edge).
   
    lower_bound -
    upper_bound -
    error_distance -
    spread_distance -
    """
    return (probability_hit_before_bound(upper_bound, error_distance,
                                         spread_distance)
            - probability_hit_before_bound(lower_bound, error_distance,
                                           spread_distance))


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius
   

def probability_hit_at_facing(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    weapon_facing -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    """
    lower_bound = arc_length(weapon_facing, distance) + target_radius
    upper_bound = arc_length(weapon_facing, distance) - target_radius
    return probability_hit_between_bounds(upper_bound, lower_bound,
                                          error_distance, spread_distance)


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def total_hit_probability(
        minimum_mean: float,
        maximum_mean: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    Return the total probability of hitting the target per second.
   
    This probability equals the integral, from ship lower angle to
    ship greater angle, the probability distribution times damage
    for each weapon.
   
    minimum_mean -
    maximum_mean -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    target_facing - target ship orientation
    """
    #we have defined spread in degrees, so we must convert it to
    #pixels to be consistent
   
    #angle of the ship's upper bound, in coordinates of the
    #distribution mean; note that this is weapon specific
    weapon_facing = transformed_angle(target_facing, minimum_mean, maximum_mean)
    return probability_hit_at_facing(weapon_facing, spread_distance,
                                     error_distance, target_facing,
                                     target_radius, distance)


def weapon_adjustment_distance(
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float) -> float:
    """
   
    weapon_facing -
    minimum_mean -
    maximum_mean -
    distance -
    """
    #segment from the old angle plus the new angle plus the weapon's
    #angle.
    angle_difference = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    #location of the weapon's mean as it tries to track the target
    #from transformed_angle
    return angle_difference / 180 * math.pi * distance


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Hit distribution function.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[j] + spread) / double_spread)
                        - min(1, max(0, bounds[j-1] + spread) / double_spread)
                        for i, bound in enumerate(bounds[1:]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                + tuple(cdf(bound) - cdf(bounds[i-1]) for i, bound in
                        enumerate(bounds[1:]))
                + (1 - cdf(bounds[-1])),)
    numbers = standard_deviation, spread
    return (probability_hit_within_bound(bounds[0], *numbers),
            + tuple(probability_hit_within_bound(bound, *numbers)
                    - probability_hit_within_bound(bounds[i-1], *numbers)
                    for i, bound in enumerate(bounds[1:]))
            + (1 - probability_hit_within_bound(bounds[-1], *numbers)),)
   

def hit_distribution_at_optimum_angle(
        spread_distance: float,
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        upper_bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_distance = weapon_adjustment_distance(weapon_facing,
                                                     minimum_mean,
                                                     maximum_mean,
                                                     distance)
    adjusted_upper_bounds = tuple(upper_bound + adjustment_distance for
                                  upper_bound in upper_bounds)
    return hit_distribution(adjusted_upper_bounds, error, spread_distance)


def main():
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
   
    target_radius = 220 #(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    cell_count = 12
    distance = 1000
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        print("spread angle", weapon["spread"])
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        print("spread distance", weapon["spread distance"])
        weapon["minimum_mean"] = (
            weapon["facing"] - (weapon["arc"] + weapon["spread"]) / 2)
        print("minimum mean", weapon["minimum_mean"])
        weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] + weapon["spread"]) / 2)
        print("maximum mean", weapon["maximum_mean"])
        print()
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_total_at_angles = [
        sum(weapon["damage"]
            * total_hit_probability(weapon["minimum_mean"],
                                    weapon["maximum_mean"],
                                    weapon["spread distance"],
                                    error_distance,
                                    target_facing,
                                    target_radius,
                                    distance)
            for weapon in weapons)
        for target_facing in range(-359,361)]

    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_total_at_angles[i+360] += (
            damage_per_second_total_at_angles[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720): damage_per_second_total_at_angles[i-360] += (
        damage_per_second_total_at_angles[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_total_at_angles = damage_per_second_total_at_angles[181:540]
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
    xaxis = range(-179,180)
   
    #the optimum angle is the midmost maximum
    #optimumangle = xaxis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))[ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    upper_bound_distances = [angle_range * 2 * math.pi * distance
                             for angle_range in angle_ranges]
    print("upper bound distances")
    for upper_bound_distance in upper_bound_distances:
        print(round(upper_bound_distance, 3))
   
    #print results
    #for weapon in weapons: print(hit_distribution_at_optimum_angle(weapon))
main()
[close]
spread angle 5
spread distance 87.26646259971648
minimum mean -22.5
maximum mean 2.5

spread angle 5
spread distance 87.26646259971648
minimum mean -2.5
maximum mean 22.5

spread angle 0
spread distance 0.0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0.0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0.0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87.26646259971648
minimum mean 87.5
maximum mean 92.5

upper bound distances
-110.0
-91.667
-73.333
-55.0
-36.667
-18.333
0.0
18.333
36.667
55.0
73.333
91.667
110.0

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 21, 2022, 07:07:15 PM
I didn't notice that you had fixed it for the new code when I posted. There is still one error here.

weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] + weapon["spread"]) / 2)

This should read

weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] - weapon["spread"]) / 2)

(The correct solution is 2.5 to 17.5 for gun 2: the turret can turn from 0 to 20, but has a spread of 5 degrees so can't align the mean exactly with its maximum angle)

What kind of output do you get for the angles now?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 21, 2022, 07:42:17 PM
spread angle 5
spread distance 87.266
minimum mean -22.5
maximum mean -2.5

spread angle 5
spread distance 87.266
minimum mean -2.5
maximum mean 17.5

spread angle 0
spread distance 0.0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0.0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0.0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87.266
minimum mean 87.5
maximum mean 87.5
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 21, 2022, 08:06:40 PM
All right, one more try. Your code will have slightly different signs than mine, because of the added brackets.

weapon["minimum_mean"] = (
            weapon["facing"] - (weapon["arc"] - weapon["spread"]) / 2)
       
        weapon["maximum_mean"] = (
            weapon["facing"] + (weapon["arc"] - weapon["spread"]) / 2)

That should be correct. Now they are symmetrical, too. My bad, I wrote this wrong in a comment above. There is a difference in pluses and minuses because I didn't use brackets.

I also notice something of an omission of mine. Specifically, this definition leads to guns without any tracking ability being able to slightly "track", to the extent that spread > arc. By our assumptions, guns with spread >= arc should not be able to track at all. For guns with no arc, this is of course because they are fixed. For guns with a positive arc and spread >= arc, this is because the recoil from a single shot is able to kick them to their other maximum angle (and therefore cause the shot location to be similarly random as if the gun had not tracked at all), so it does not matter how they are angled originally.

This is fixed like so:

weapons <- cbind(weapons, maxmean=(weapons$facing+weapons$trackingrange/2-weapons$spread/2))
weapons <- cbind(weapons, minmean=(weapons$facing-weapons$trackingrange/2+weapons$spread/2))
weapons[which(weapons$minmean >= weapons$maxmean),]$minmean <- weapons[which(weapons$minmean >= weapons$maxmean),]$facing
weapons[which(weapons$minmean >= weapons$maxmean),]$maxmean <- weapons[which(weapons$minmean >= weapons$maxmean),]$facing

(an alternative of course is to use a loop and do max(maxmean, facing) and min(minmean, facing)

Output:

> weapons
  damage facing trackingrange spread maxmean minmean
1    100    -10            20      5    -2.5   -17.5
2    100     10            20      5    17.5     2.5
3     30   -160            20      0  -150.0  -170.0
4     30    180            20      0   190.0   170.0
5     30    160            20      0   170.0   150.0
6    120     90             0      5    90.0    90.0


Before cut and paste:
(https://i.ibb.co/0JbyS07/image.png) (https://ibb.co/x5Tq0nk)

After cut and paste:
(https://i.ibb.co/9Tv2mk5/image.png) (https://ibb.co/bB3LjM9)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 22, 2022, 01:32:34 AM
Code
Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
               
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    if b > 0: return (f(x, NormalDist(0, standard_deviation),
                        uniform_distribution_radius) if a > 0
                      else max(0, min(1, b / 2 + x)))
    return 0 if x < 0 else 1


def probability_hit_between_bounds(
        lower_bound: float,
        upper_bound: float,
        error_distance: float,
        spread_distance: float) -> float:
    """
    The probability to hit between the left and right edges of the
    ship is the integral, from the lower edge of the ship to the
    upper one, of the hit probability distribution. By the
    fundamental theorem of calculus, this integral equals
   
    CDF(upper edge) - CDF(lower edge).
   
    lower_bound -
    upper_bound -
    error_distance -
    spread_distance -
    """
    return (probability_hit_before_bound(upper_bound, error_distance,
                                         spread_distance)
            - probability_hit_before_bound(lower_bound, error_distance,
                                           spread_distance))


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius
   

def probability_hit_at_facing(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    weapon_facing -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    """
    lower_bound = arc_length(weapon_facing, distance) + target_radius
    upper_bound = arc_length(weapon_facing, distance) - target_radius
    return probability_hit_between_bounds(upper_bound, lower_bound,
                                          error_distance, spread_distance)


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def total_hit_probability(
        minimum_mean: float,
        maximum_mean: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    Return the total probability of hitting the target per second.
   
    This probability equals the integral, from ship lower angle to
    ship greater angle, the probability distribution times damage
    for each weapon.
   
    minimum_mean -
    maximum_mean -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    target_facing - target ship orientation
    """
    #we have defined spread in degrees, so we must convert it to
    #pixels to be consistent
   
    #angle of the ship's upper bound, in coordinates of the
    #distribution mean; note that this is weapon specific
    weapon_facing = transformed_angle(target_facing, minimum_mean, maximum_mean)
    return probability_hit_at_facing(weapon_facing, spread_distance,
                                     error_distance, target_facing,
                                     target_radius, distance)


def weapon_adjustment_distance(
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float) -> float:
    """
   
    weapon_facing -
    minimum_mean -
    maximum_mean -
    distance -
    """
    #segment from the old angle plus the new angle plus the weapon's
    #angle.
    angle_difference = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    #location of the weapon's mean as it tries to track the target
    #from transformed_angle
    return angle_difference / 180 * math.pi * distance


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Hit distribution function.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[j] + spread) / double_spread)
                        - min(1, max(0, bounds[j-1] + spread) / double_spread)
                        for i, bound in enumerate(bounds[1:]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                + tuple(cdf(bound) - cdf(bounds[i-1]) for i, bound in
                        enumerate(bounds[1:]))
                + (1 - cdf(bounds[-1])),)
    numbers = standard_deviation, spread
    return (probability_hit_within_bound(bounds[0], *numbers),
            + tuple(probability_hit_within_bound(bound, *numbers)
                    - probability_hit_within_bound(bounds[i-1], *numbers)
                    for i, bound in enumerate(bounds[1:]))
            + (1 - probability_hit_within_bound(bounds[-1], *numbers)),)
   

def hit_distribution_at_optimum_angle(
        spread_distance: float,
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        upper_bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_distance = weapon_adjustment_distance(weapon_facing,
                                                     minimum_mean,
                                                     maximum_mean,
                                                     distance)
    adjusted_upper_bounds = tuple(upper_bound + adjustment_distance for
                                  upper_bound in upper_bounds)
    return hit_distribution(adjusted_upper_bounds, error, spread_distance)


def main():
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
    target_radius = 220#(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    cell_count = 12
    distance = 1000
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        half_difference = (weapon["arc"] - weapon["spread"]) / 2
        weapon["minimum mean"] = weapon["facing"] - half_difference
        weapon["maximum mean"] = weapon["facing"] + half_difference
        print("spread angle", weapon["spread"])
        print("spread distance", round(weapon["spread distance"]))
        print("minimum mean", weapon["minimum mean"])
        print("maximum mean", weapon["maximum mean"])
        print()
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_total_at_angles = [
        sum(weapon["damage"]
            * total_hit_probability(weapon["minimum mean"],
                                    weapon["maximum mean"],
                                    weapon["spread distance"],
                                    error_distance,
                                    target_facing,
                                    target_radius,
                                    distance)
            for weapon in weapons)
        for target_facing in range(-359,361)]

    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_total_at_angles[i+360] += (
            damage_per_second_total_at_angles[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720):
        damage_per_second_total_at_angles[i-360] += (
            damage_per_second_total_at_angles[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_total_at_angles = damage_per_second_total_at_angles[181:540]
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
    xaxis = range(-179,180)
   
    #the optimum angle is the midmost maximum
    #optimumangle = xaxis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))[ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    upper_bound_distances = [angle_range * 2 * math.pi * distance
                             for angle_range in angle_ranges]
    print("upper bound distances")
    for upper_bound_distance in upper_bound_distances:
        print(round(upper_bound_distance, 3))
   
    #print results
    #for weapon in weapons: print(hit_distribution_at_optimum_angle(weapon))
main()
[close]
Results
spread angle 5
spread distance 87
minimum mean -17.5
maximum mean -2.5

spread angle 5
spread distance 87
minimum mean 2.5
maximum mean 17.5

spread angle 0
spread distance 0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87
minimum mean 92.5
maximum mean 87.5

upper bound distances
-110.0
-91.667
-73.333
-55.0
-36.667
-18.333
0.0
18.333
36.667
55.0
73.333
91.667
110.0
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 22, 2022, 01:55:07 AM
Those numbers now seem to be the same as mine, other than the last weapon which is suffering from the above mentioned omission.

What kind of results do you get for dps at angles with these?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 22, 2022, 07:51:21 AM
Code
Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
               
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    if b > 0: return (f(x, NormalDist(0, standard_deviation),
                        uniform_distribution_radius) if a > 0
                      else max(0, min(1, b / 2 + x)))
    return 0 if x < 0 else 1


def probability_hit_between_bounds(
        lower_bound: float,
        upper_bound: float,
        error_distance: float,
        spread_distance: float) -> float:
    """
    The probability to hit between the left and right edges of the
    ship is the integral, from the lower edge of the ship to the
    upper one, of the hit probability distribution. By the
    fundamental theorem of calculus, this integral equals
   
    CDF(upper edge) - CDF(lower edge).
   
    lower_bound -
    upper_bound -
    error_distance -
    spread_distance -
    """
    return (probability_hit_before_bound(upper_bound, error_distance,
                                         spread_distance)
            - probability_hit_before_bound(lower_bound, error_distance,
                                           spread_distance))


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius
   

def probability_hit_at_facing(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    weapon_facing -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    """
    lower_bound = arc_length(weapon_facing, distance) + target_radius
    upper_bound = arc_length(weapon_facing, distance) - target_radius
    return probability_hit_between_bounds(upper_bound, lower_bound,
                                          error_distance, spread_distance)


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def total_hit_probability(
        minimum_mean: float,
        maximum_mean: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    Return the total probability of hitting the target per second.
   
    This probability equals the integral, from ship lower angle to
    ship greater angle, the probability distribution times damage
    for each weapon.
   
    minimum_mean -
    maximum_mean -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    target_facing - target ship orientation
    """
    #we have defined spread in degrees, so we must convert it to
    #pixels to be consistent
   
    #angle of the ship's upper bound, in coordinates of the
    #distribution mean; note that this is weapon specific
    weapon_facing = transformed_angle(target_facing, minimum_mean, maximum_mean)
    return probability_hit_at_facing(weapon_facing, spread_distance,
                                     error_distance, target_facing,
                                     target_radius, distance)


def weapon_adjustment_distance(
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float) -> float:
    """
   
    weapon_facing -
    minimum_mean -
    maximum_mean -
    distance -
    """
    #segment from the old angle plus the new angle plus the weapon's
    #angle.
    angle_difference = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    #location of the weapon's mean as it tries to track the target
    #from transformed_angle
    return angle_difference / 180 * math.pi * distance


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Hit distribution function.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[j] + spread) / double_spread)
                        - min(1, max(0, bounds[j-1] + spread) / double_spread)
                        for i, bound in enumerate(bounds[1:]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                + tuple(cdf(bound) - cdf(bounds[i-1]) for i, bound in
                        enumerate(bounds[1:]))
                + (1 - cdf(bounds[-1])),)
    numbers = standard_deviation, spread
    return (probability_hit_within_bound(bounds[0], *numbers),
            + tuple(probability_hit_within_bound(bound, *numbers)
                    - probability_hit_within_bound(bounds[i-1], *numbers)
                    for i, bound in enumerate(bounds[1:]))
            + (1 - probability_hit_within_bound(bounds[-1], *numbers)),)
   

def hit_distribution_at_optimum_angle(
        spread_distance: float,
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        upper_bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_distance = weapon_adjustment_distance(weapon_facing,
                                                     minimum_mean,
                                                     maximum_mean,
                                                     distance)
    adjusted_upper_bounds = tuple(upper_bound + adjustment_distance for
                                  upper_bound in upper_bounds)
    return hit_distribution(adjusted_upper_bounds, error, spread_distance)


def main():
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
    target_radius = 220#(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    cell_count = 12
    distance = 1000
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        half_difference = (weapon["arc"] - weapon["spread"]) / 2
        minimum_mean = weapon["facing"] - half_difference
        maximum_mean = weapon["facing"] + half_difference
        weapon["minimum mean"] = min(minimum_mean, maximum_mean)
        weapon["maximum mean"] = max(minimum_mean, maximum_mean)
        print("spread angle", weapon["spread"])
        print("spread distance", round(weapon["spread distance"]))
        print("minimum mean", weapon["minimum mean"])
        print("maximum mean", weapon["maximum mean"])
        print()
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_total_at_angles = [
        sum(weapon["damage"]
            * total_hit_probability(weapon["minimum mean"],
                                    weapon["maximum mean"],
                                    weapon["spread distance"],
                                    error_distance,
                                    target_facing,
                                    target_radius,
                                    distance)
            for weapon in weapons)
        for target_facing in range(-359,361)]
   
    for damage_per_second in damage_per_second_total_at_angles:
        print(damage_per_second)
    print()
    #import matplotlib.pyplot as plt
    #plt.scatter(range(-359,361), damage_per_second_total_at_angles, s=1)

    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_total_at_angles[i+360] += (
            damage_per_second_total_at_angles[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720):
        damage_per_second_total_at_angles[i-360] += (
            damage_per_second_total_at_angles[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_total_at_angles = damage_per_second_total_at_angles[181:540]
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
    xaxis = range(-179,180)
   
    #the optimum angle is the midmost maximum
    #optimumangle = xaxis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))[ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    upper_bound_distances = [angle_range * 2 * math.pi * distance
                             for angle_range in angle_ranges]
    print("upper bound distances")
    for upper_bound_distance in upper_bound_distances:
        print(round(upper_bound_distance, 3))
   
    #print results
    #for weapon in weapons: print(hit_distribution_at_optimum_angle(weapon))
main()
[close]
Result
spread angle 5
spread distance 87
minimum mean -17.5
maximum mean -2.5

spread angle 5
spread distance 87
minimum mean 2.5
maximum mean 17.5

spread angle 0
spread distance 0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87
minimum mean 87.5
maximum mean 92.5

-3.2820535971098606
-3.3135906944555766
-3.34514414268445
-3.376707177170404
-3.4082728973770395
-3.439834266258156
-3.4713841096844815
-3.502915115905303
-3.5344198350486358
-3.565890678649966
-3.5973199192219685
-3.6286996898577435
-3.660021983873083
-3.6912786544849823
-3.7224614145326727
-3.7535618362326355
-3.784571350978516
-3.8154812491828274
-3.8462826801564405
-3.8769666520307857
-3.907524031730425
-3.937945544980251
-3.9682217763647283
-3.9983431694274443
-4.028300026821866
-4.058082510503623
-4.087680641976378
-4.117084302580308
-4.146283233832568
-4.175267037820703
-4.204025177636347
-4.232546977871436
-4.260821625159095
-4.288838168769372
-4.316585521255061
-4.3440524591564715
-4.371227623753389
-4.3980995218773185
-4.424656526777004
-4.450886879036668
-4.4767786875579825
-4.502319930590731
-4.527498456825037
-4.552301986542926
-4.576718112823278
-4.6007343028104435
-4.624337899040363
-4.647516120825085
-4.670256065696661
-4.692544710918924
-4.714368915047655
-4.735715419564935
-4.756570850562816
-4.776921720501513
-4.7967544300179155
-4.816055269804524
-4.834810422546442
-4.8530059649270765
-4.8706278696887235
-4.887662007762808
-4.904094150462571
-4.919909971736657
-4.9350950504907924
-4.949634872967564
-4.963514835196875
-4.976720245504296
-4.989236327089034
-5.001048220658516
-5.0121409871347735
-5.022499610419795
-5.032109000228114
-5.040953994977563
-5.049019364749837
-5.056289814311637
-5.062749986198836
-5.068384463862393
-5.0731777748808575
-5.077114394232592
-5.080178747630633
-5.082355214918062
-5.08362813352756
-5.083981802000879
-5.0834004835677264
-5.0818684097862485
-5.0793697842426955
-5.075888786311489
-5.071409574972549
-5.0659162926845624
-5.059393069322407
-5.051824026164122
-5.043193279938437
-5.0334849469277785
-5.0226831471201185
-5.0107720084263985
-4.9977356709386065
-4.983558291248478
-4.968224046814518
-4.951717140381063
-4.934021804445714
-4.915122305775681
-4.895002949973522
-4.873648086087961
-4.851042111271008
-4.827169475482561
-4.802014686229808
-4.7755623133621725
-4.747796993896923
-4.718703436888821
-4.688266428338942
-4.656470836140956
-4.623301615062696
-4.588743811765502
-4.552782569852069
-4.515403134952209
-4.476590859837726
-4.436331209563721
-4.394609766642487
-4.351412236241536
-4.306724451408391
-4.260532378314952
-4.212822121528694
-4.163579929300067
-4.112792198875296
-4.060445481815902
-4.00652648934203
-3.95102209768703
-3.8939193534647494
-3.835205479044342
-3.7748678779367237
-3.712894140185099
-3.6492720477667504
-3.5839895799870245
-3.5170349188826853
-3.4483964546263066
-3.3780627909191763
-3.306022750387851
-3.232265379972368
-3.1567799563027985
-3.0795559910718695
-3.0005832363878433
-2.9198516901190197
-2.8373516012205173
-2.7530734750416723
-2.6670080786126276
-2.5791464459114906
-2.4894798831050857
-2.3979999737620274
-2.304698584040219
-2.2095678678421065
-2.112600271934773
-2.0137885410425236
-1.9131257228925511
-1.810605173229394
-1.7062205607851273
-1.599965872205455
-1.4918354169299815
-1.3818238320173286
-1.2699260868041773
-1.1561374864228573
-1.0404536659038104
-0.9228705140332427
-0.8033836369788241
-0.681985192072097
-0.5586476697515204
-0.4332516968550677
-0.30530779143291986
-0.17303382897957853
-0.030748578422636452
0.13730942355900844
0.36896072783760125
0.7420160102482036
1.3918045203622023
2.5122334301289255
4.319644758608188
6.971515858148949
10.465096896036219
14.573919942770068
18.87787927240069
22.89139398953491
26.227153712358053
28.706871559839705
30.369012048489534
31.391869382551068
31.992413565777795
32.35371580920003
32.59805644707593
32.79319212828767
32.97097165276466
33.14423982992319
33.31740595657078
33.49193920415485
33.66830205357945
33.846485428863595
34.026479800092055
34.208275181903005
34.39186113209997
34.57722675038761
34.76436067723418
34.95325109285854
35.143885716348244
35.336251804903966
35.53033615321892
35.72612509298679
35.923604492543085
36.12275975664425
36.32357582637856
36.526037179213326
36.73012782918302
36.935831327212476
37.14313076158033
37.35153765394254
37.55955682466322
37.76275295701745
37.948255208980605
38.084188734398126
38.10277082364171
37.881695066320496
37.237864159054524
35.95428198872228
33.8526282239425
30.894412202579932
27.25789464087836
23.330359736080567
19.59736217863843
16.478981468666095
14.2016592884509
12.768119201693791
12.020857564859506
11.743506045218234
11.74213113440937
11.881377223517491
12.0834072542994
12.31037427034324
12.546490379653854
12.786047899420874
13.027238230464777
13.269545393437733
13.512823495332091
13.757018484638412
14.002095955071983
14.248024928011791
14.494774686864389
14.742314241345722
14.990612252077113
15.239637025505806
15.489356518321822
15.739738343102214
15.990749774199212
16.242357753774755
16.494528897961402
16.747229503157712
17.000425552449475
17.25408272215823
17.508166388513747
17.762641634452145
18.01747325653442
18.2726257719845
18.528063425848753
18.783750198267686
19.039649811866617
19.295725739256383
19.551941210645616
19.808259221561656
20.06464254067921
20.321053717752253
20.57745509164933
20.833808798488107
21.090076779868824
21.34622079120235
21.602202410132847
21.857983045049174
22.11352394368748
22.36878620181757
22.623730772011328
22.87831847249686
23.13250999608564
23.38626591917749
23.639546710837163
23.89231274194401
24.144524294403745
24.39614157043013
24.647124701883225
24.897433759670925
25.14702876320241
25.395869689897758
25.64391648474382
25.891129069901158
26.13746735435099
26.382891243585373
26.627360649334292
26.870835499326866
27.11327574708475
27.354641381743434
27.59489243789921
27.833989005477008
28.071891239617894
28.308559370582103
28.54395371366303
28.77803467911201
29.010762782068117
29.242098652489084
29.47200304508352
29.700436849238297
29.927361098936625
30.152736982668937
30.376525853325266
30.598689238074634
30.819188848221046
31.037986589034965
31.25504456955943
31.47032511238488
31.683790763387837
31.89540430143564
32.10512874804794
32.31292737701511
32.51876372397008
32.72260159590799
32.9244050806546
33.124138556274076
33.32176670042082
33.51725449962376
33.7105672585072
33.901670608939206
34.09053051910873
34.277113302526075
34.46138562694359
34.64331452319422
34.82286739394458
35.00001202236037
35.1747165806786
35.34694963868604
35.516680172102056
35.68387757085677
35.848511647270925
36.01055264412556
36.1699712426252
36.32673857024854
36.4808262084842
36.63220620045057
36.78085105839363
36.92673377106532
37.06982781097359
37.21027705046748
37.349075348853205
37.48636728397101
37.622127739140645
37.75633181890341
37.88895485575978
38.01997241686243
38.14936031066048
38.27709459349329
38.40315157613202
38.52750783026435
38.650140194924745
38.77102578286064
38.89014198684078
39.007466485896614
39.122807342539
39.235123428740565
39.34422370725858
39.45008765737707
39.552695371642315
39.65219747034401
39.749594686983585
39.845038598157174
39.93851120876438
40.029994865966664
40.11947226410167
40.2069264495074
40.29234082525696
40.37569915580094
40.45698557151623
40.53618457315926
40.61328103622262
40.688260215193026
40.761107747708856
40.83180965861667
40.90018245496407
40.96519354972549
41.02666054082987
41.08457163457641
41.13891574000802
41.1896824720405
41.23686215438164
41.28044582224399
41.320425224847654
41.3567928277116
41.38954181473687
41.418666090074495
41.4441602797836
41.46601973327462
41.48424052454111
41.49881945317482
41.50975404516922
41.51704255350595
41.52068395852953
41.5206779681041
41.51702501755746
41.50972626941029
41.49878361289036
41.48419966323183
41.465977760762215
41.44412196977461
41.4186370771866
41.38952859098906
41.35680273847958
41.32046646428783
41.28052742818943
41.23699400271066
41.18987527052482
41.1391810216412
41.0849217503889
41.02710865219381
40.96575362015434
40.90086924141206
40.832468793325575
40.76056623944005
40.68517622526355
40.6063140738463
40.52399578116263
40.43823801130495
40.34905809148482
40.25647400684525
40.16050439508798
40.06116854091475
39.95848637028776
39.85247844450864
39.74316595412099
39.6305707126375
39.5147151500927
39.39562230642766
39.273315824704234
39.14781994415695
39.01915949307906
38.8873598815517
38.75244709401427
38.61444768168133
38.473388754807274
38.32929797480523
38.18220354621535
38.03213420853732
37.879119227918274
37.72318838870861
37.56437198488064
37.402700811321935
37.23820615499939
37.070919786001134
36.90107783921009
36.72993630125774
36.55773187087327
36.3844974457323
36.210266003913745
36.034866703872865
35.857109379027634
35.67682365885986
35.49404346343189
35.30880310321045
35.121137269556385
34.93108102513361
34.73866979423409
34.54393935303651
34.34692581978378
34.14766564489908
33.94619560103097
33.74255277304552
33.53677454795043
33.328898604776015
33.118962904394365
32.90700567929957
32.69306542334442
32.47718088142963
32.25939103916608
32.03973511249759
31.818252537299422
31.594982958945465
31.369966221861205
31.143242359061738
30.91485158179396
30.68483427025255
30.453230973652154
30.22008248048641
29.985430348953216
29.74932006717301
29.51181726518549
29.273079905542872
29.033636522328216
28.795308748708422
28.563817774667402
28.35496546637236
28.20661321510807
28.19661296826736
28.460335671669533
29.191730611688676
30.607182333443255
32.864211120376126
35.96011046927365
39.66845617188314
43.569186608656096
47.17676537022511
50.103926944213086
52.17242977638645
53.42078470148822
54.027336397272975
54.209125711857006
54.14938726561583
53.97093560965532
53.743354948205166
53.50422143618438
53.28218816035768
53.1195324694701
53.095582395738646
53.34617853516659
54.065268526466106
55.46923507525599
57.71559643513384
60.80164388424283
64.50095080847355
68.39345299674129
71.99361126295696
74.91415712401971
76.97684578201584
78.22018401598132
78.82250846712013
79.00083174562333
78.93826783348783
78.75713999554611
78.52524796595051
78.27448377970497
78.01773562622635
77.75945632239106
77.5006875903575
77.24045835214268
76.97438642178773
76.68965421426955
76.35444025920678
75.90101533405763
75.2071266193434
74.08973049895603
72.33188463591856
69.75532255638612
66.3216075296631
62.20905426985097
57.80500103097431
53.59505661170322
49.999354664318396
47.24439105972483
45.3329435792116
44.10756281831123
43.35193469735459
42.8721799675573
42.53252617402792
42.25371316411505
41.993531194404156
41.723394496609444
41.40579393486059
40.97121201206156
40.296900559868774
39.19969100394836
37.46260819319036
34.90737278560176
31.495538908795268
27.405412693893346
23.024323832701413
18.837872504646477
15.266183678161527
12.535744477462284
10.649323877179773
9.449463609182942
8.7198406761295
8.266566860516406
7.954331795255898
7.705343610393475
7.481800479755517
7.267959579028006
7.058158225996191
6.850632750487318
6.644912026022219
6.440894931816638
6.23857210338752
6.037953731749087
5.839053341289836
5.64188461959497
5.44646087716287
5.252794967425325
5.0608992770994154
4.870785726014708
4.682465768168513
4.495950393006338
4.3112501268531656
4.128375034447451
3.947334720617044
3.768138332056421
3.5907945592238333
3.4153116383679682
3.241697353651105
3.0699590393957754
2.900103582430331
2.7321374245597063
2.5660665651295034
2.4018965637018264
2.2396325428213038
2.0792791909088137
1.9208407652330095
1.764321094975707
1.6097235844124036
1.4570512161778115
1.3063065546126706
1.1574917492183712
1.0106085381797891
0.8656582519937173
0.7226418171680882
0.581559760003385
0.44241221045887347
0.3051989060932314
0.16991919608445372
0.03657204531140046
-0.0948439614735408
-0.22433061542358246
-0.351890079291044
-0.47752488307384766
-0.6012379195808926
-0.7230324399502752
-0.8429120491019937
-0.9608807011386977
-1.076942694687122
-1.1911026681868808
-1.3033655951331236
-1.4137367792748634
-1.5222218497511486
-1.6288267562000192
-1.7335577638178146
-1.8364214483816088
-1.937424691228471
-2.0365746742066815
-2.133878874586963
-2.229345059946697
-2.322981283024199
-2.4147958765336375
-2.504797447984699
-2.592994874440784
-2.6793972972839253
-2.764014116945419
-2.846854987629448
-2.9279298120105546
-3.0072487359268463
-3.0848221430574707
-3.160660649585645
-3.2347750988721513
-3.307176556086482
-3.3778763028817504
-3.446885832023745
-3.514216842054525
-3.5798812319273
-3.6438910956687165
-3.706258717025954
-3.7669965641408654
-3.8261172842170277
-3.883633698199116
-3.9395587954750244
-3.9939057285718604
-4.046687807891964
-4.097918496435131
-4.147611404574536
-4.195780284811557
-4.242439026599287
-4.287601651139243
-4.331282306240993
-4.373495261189619
-4.414254901639927
-4.453575724546486
-4.491472333115629
-4.527959431796176
-4.563051821297455
-4.596764393644013
-4.629112127254706
-4.660110082088051
-4.689773394777972
-4.718117273852145
-4.745156994965574
-4.770907896182757
-4.795385373302876
-4.818604875217991
-4.8405818993294325
-4.861331986999038
-4.880870719056336
-4.899213711331654
-4.9163766102644235
-4.932375088545244
-4.947224840800599
-4.960941579342193
-4.973541029963129
-4.985038927779555
-4.995451013131644
-5.004793027538312
-5.013080709694258
-5.020329791539537
-5.026555994373356
-5.031775025016492
-5.036002572053544
-5.039254302107152
-5.041545856176999
-5.042892846049751
-5.043310850748481
-5.042815413049673
-5.041422036059577
-5.039146179852136
-5.036003258154267
-5.032008635108598
-5.027177622082535
-5.021525474545587
-5.015067389002188
-5.007818499993539
-4.999793877145136
-4.991008522294029
-4.981477366671694
-4.971215268128719
-4.960237008461403
-4.948557290750428
-4.936190736810371
-4.9231518846628575

upper bound distances
-110.0
-91.667
-73.333
-55.0
-36.667
-18.333
0.0
18.333
36.667
55.0
73.333
91.667
110.0
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 22, 2022, 09:50:36 AM
Oh, this has turned into quite the bug hunt. The output at least seems to exhibit a more reasonable spectrum, but something is still very wrong as long as you are getting negative numbers.

Maybe this function has an issue?

def probability_hit_at_facing(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    weapon_facing -
    spread_distance -
    error_distance -
    target_facing -
    target_radius -
    distance -
    """
    lower_bound = arc_length(weapon_facing, distance) + target_radius
    upper_bound = arc_length(weapon_facing, distance) - target_radius
    return probability_hit_between_bounds(upper_bound, lower_bound,
                                          error_distance, spread_distance)


I think it's supposed to do the same as my


    shipupperbound <- deg_to_arc(transformed_angle(angle,weapons[i,])+shipwidth/2)
    shiplowerbound <- deg_to_arc(transformed_angle(angle,weapons[i,])-shipwidth/2)
    #we have defined spread in degrees, so we must convert it to pixels to be consistent
    pxspread <- deg_to_arc(weapons[i,4])
   
    damage <- weapons[i,1]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(shipupperbound, error, pxspread) -
        hit_probability_coord_lessthan_x(shiplowerbound, error, pxspread)
 


I have two questions about this: 1) what does arc_length(weapon_facing, distance) do? Does it return the arc from 0 to weapon facing? If so, then that will be incorrect, because to use the probability distribution all coordinates must be relative to the probability distribution (ie. mean = 0, +50px = 50 px above mean, etc). The equations for the distribution assume the mean is 0, which is why we must transform to coordinates of the weapon (and more exactly the mean of the shot distribution from the weapon) for each weapon separately. And of course an integral from -1000 to -950 is not the same as an integral from 0 to 50. So arc length is the correct thing to use but it must be the arc length from the mean of the distribution to the coordinate, with sign. 2) Aren't the upper and lower bounds reversed in your code?

I'm starting to think maybe it could be easier to re-do it with an extremely literal (no new functions, making expressions more elegant, etc.) translation of these smaller functions since there aren't that many, than try to squash the apparently several bugs we are facing, but of course whatever you think is best.

Specifically to fix this if the code does what I think it does you should write

    lower_bound = weapon_adjustment_distance(args) - target_radius
    upper_bound =  weapon_adjustment_distance(args) + target_radius
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 22, 2022, 11:09:05 AM
I have read your advice, thought, poked, and rearranged the code until it produces almost the same output as yours does.  I still get some negative probabilities, but they are usually extremely small.  If this approach doesn't work, then let's go function by function.

Code
Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
               
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    if b > 0: return (f(x, NormalDist(0, standard_deviation),
                        uniform_distribution_radius) if a > 0
                      else max(0, min(1, b / 2 + x)))
    return 0 if x < 0 else 1


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def total_hit_probability(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        target_facing: float,
        target_radius: float,
        distance: float) -> float:
    """
    Return the total probability of hitting the target per second.
   
    This probability equals the integral, from ship lower angle to
    ship greater angle, of the probability distribution. By the
    fundamental theorem of calculus, this integral equals
   
    CDF(upper edge) - CDF(lower edge).
   
    weapon_facing -
    spread_distance -
    error_distance -
    target_facing - target ship orientation
    target_radius -
    distance -
    """
    #angle of the ship's upper bound, in coordinates of the
    #distribution mean; note that this is weapon specific
   


def weapon_adjustment_distance(
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float) -> float:
    """
   
    weapon_facing -
    minimum_mean -
    maximum_mean -
    distance -
    """
    #segment from the old angle plus the new angle plus the weapon's
    #angle.
    angle_difference = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    #location of the weapon's mean as it tries to track the target
    #from transformed_angle
    return angle_difference / 180 * math.pi * distance


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Hit distribution function.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[j] + spread) / double_spread)
                        - min(1, max(0, bounds[j-1] + spread) / double_spread)
                        for i, bound in enumerate(bounds[1:]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                + tuple(cdf(bound) - cdf(bounds[i-1]) for i, bound in
                        enumerate(bounds[1:]))
                + (1 - cdf(bounds[-1])),)
    numbers = standard_deviation, spread
    return (probability_hit_within_bound(bounds[0], *numbers),
            + tuple(probability_hit_within_bound(bound, *numbers)
                    - probability_hit_within_bound(bounds[i-1], *numbers)
                    for i, bound in enumerate(bounds[1:]))
            + (1 - probability_hit_within_bound(bounds[-1], *numbers)),)
   

def hit_distribution_at_optimum_angle(
        spread_distance: float,
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        upper_bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_distance = weapon_adjustment_distance(weapon_facing,
                                                     minimum_mean,
                                                     maximum_mean,
                                                     distance)
    adjusted_upper_bounds = tuple(upper_bound + adjustment_distance for
                                  upper_bound in upper_bounds)
    return hit_distribution(adjusted_upper_bounds, error, spread_distance)


def main():
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
    distance = 1000
    cell_count = 12
    target_radius = 220#(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    target_arc_measure = arc_measure(target_radius, distance)
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        #we have defined spread in degrees, so we must convert it to
        #pixels to be consistent
        half_difference = (weapon["arc"] - weapon["spread"]) / 2
        minimum_mean = weapon["facing"] - half_difference
        maximum_mean = weapon["facing"] + half_difference
        weapon["minimum mean"] = min(minimum_mean, maximum_mean)
        weapon["maximum mean"] = max(minimum_mean, maximum_mean)
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        print("spread angle", weapon["spread"])
        print("spread distance", round(weapon["spread distance"]))
        print("minimum mean", weapon["minimum mean"])
        print("maximum mean", weapon["maximum mean"])
        print()
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_totals_expected = []
    for target_facing in range(-359,361):
        damage_per_second_total_expected = 0
        for weapon in weapons:
            weapon_facing = transformed_angle(target_facing,
                                              weapon["minimum mean"],
                                              weapon["maximum mean"])
            weapon_arc = arc_length(weapon_facing, distance)
            lower_bound = arc_length(weapon_arc - target_radius, distance)
            upper_bound = arc_length(weapon_arc + target_radius, distance)
            probability = (
                probability_hit_before_bound(upper_bound, error_distance,
                                             weapon["spread distance"])
                - probability_hit_before_bound(lower_bound, error_distance,
                                               weapon["spread distance"]))
            damage_per_second_total_expected += weapon["damage"] * probability
        damage_per_second_totals_expected.append(
            damage_per_second_total_expected)
   
    print("damage per second totals expected")
    for damage_per_second in damage_per_second_totals_expected:
        print(damage_per_second)
    print()

    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_totals_expected[i+360] += (
            damage_per_second_totals_expected[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720):
        damage_per_second_totals_expected[i-360] += (
            damage_per_second_totals_expected[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_totals_expected = (
        damage_per_second_totals_expected[181:540])
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
    xaxis = range(-179,180)
   
    #the optimum angle is the midmost maximum
    #optimumangle = xaxis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))[ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    upper_bound_distances = [angle_range * 2 * math.pi * distance
                             for angle_range in angle_ranges]
    print("upper bound distances")
    for upper_bound_distance in upper_bound_distances:
        print(round(upper_bound_distance, 3))
   
    #print results
    #for weapon in weapons: print(hit_distribution_at_optimum_angle(weapon))
main()
[close]
Result
spread angle 5
spread distance 87
minimum mean -17.5
maximum mean -2.5

spread angle 5
spread distance 87
minimum mean 2.5
maximum mean 17.5

spread angle 0
spread distance 0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87
minimum mean 87.5
maximum mean 92.5

damage per second totals expected
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
1.68e-320
1.7984e-318
1.8769455e-316
1.9301718415e-314
1.955645083155e-312
1.95224214685523e-310
1.920113422803395e-308
1.860671099208667e-306
1.7764862222750104e-304
1.6711042820479386e-302
1.5487977556896792e-300
1.4142796501932532e-298
1.2724047162089782e-296
1.127884468491377e-294
9.850387215885852e-293
8.476006901797427e-291
7.1858572296518e-289
6.002264394761905e-287
4.939703538991761e-285
4.005307322938467e-283
3.1997789025996644e-281
2.518565400302636e-279
1.9531500122516618e-277
1.4923374696689427e-275
1.1234339129504213e-273
8.33253272650974e-272
6.089133649450087e-270
4.3841250430303616e-268
3.10999201232515e-266
2.1736243799289552e-264
1.4967820560327995e-262
1.0155035689732907e-260
6.788176413625958e-259
4.4706794553624815e-257
2.9009661075659753e-255
1.8546435457670188e-253
1.1682260581186694e-251
7.250065477445754e-250
4.43307965009108e-248
2.670654262992146e-246
1.585178942136948e-244
9.27016305392818e-243
5.34127410964571e-241
3.032150368090636e-239
1.695918349341388e-237
9.345605393201283e-236
5.074090341537414e-234
2.7142957434541383e-232
1.4305541742259278e-230
7.428473582254883e-229
3.800519332326511e-227
1.915730238377673e-225
9.51423259658171e-224
4.655444274477199e-222
2.2443801225658372e-220
1.066054880729841e-218
4.988965884376434e-217
2.300325433849941e-215
1.0449987171963941e-213
4.6772427956596835e-212
2.0625842890201398e-210
8.961507662422936e-209
3.8361712896866514e-207
1.6179399029849109e-205
6.723171905662245e-204
2.7525386697391285e-202
1.1102990629375278e-200
4.412592232877422e-199
1.7278054693569646e-197
6.665658563911062e-196
2.5336020402048966e-194
9.488133733667092e-193
3.500823371401459e-191
1.272642873976318e-189
4.558162364061525e-188
1.6084955501795387e-186
5.5923804585751036e-185
1.915668102949035e-183
6.465326751503901e-182
2.1498456455676497e-180
7.043209530812415e-179
2.2734237998172184e-177
7.229971682795947e-176
2.265369901001839e-174
6.993392848328534e-173
2.127075605689095e-171
6.374176189848741e-170
1.8819638058238247e-168
5.4745000792802185e-167
1.569002342515636e-165
4.430457470071053e-164
1.2325919685631307e-162
3.378592419604738e-161
9.124265341093612e-160
2.427758968553293e-158
6.364418195100678e-157
1.6438313282919252e-155
4.183127685389295e-154
1.0487940934579873e-152
2.5907440342315568e-151
6.305272496685117e-150
1.5119179035212525e-148
3.5718850183814047e-147
8.31402938664258e-146
1.9066473642824586e-144
4.307981681755567e-143
9.590072196229647e-142
2.1033636878590383e-140
4.545181614723387e-139
9.676813924241621e-138
2.0298209130884376e-136
4.194953411762568e-135
8.541625954548258e-134
1.713554195252012e-132
3.3868731097185966e-131
6.59543873026592e-130
1.2654122443501598e-128
2.3920147102548325e-127
4.4549109063291234e-126
8.174429510981928e-125
1.47781144295818e-123
2.632228730546359e-122
4.61924649653465e-121
7.986591045979641e-120
1.360487113727282e-118
2.283336789525175e-117
3.77561713775355e-116
6.151035469470356e-115
9.873035420896787e-114
1.561331820562775e-112
2.4326613071321607e-111
3.7343057338882287e-110
5.64780569808393e-109
8.415717271117996e-108
1.2355032719284477e-106
1.7870548965167943e-105
2.546671766038838e-104
3.575600515998586e-103
4.946133077640703e-102
6.740983094567758e-101
9.051512565843067e-100
1.1974555490883665e-98
1.5607660284447277e-97
2.0042698946351503e-96
2.535796701556749e-95
3.1609113959285636e-94
3.881947871581435e-93
4.6970630249628994e-92
5.599409220640267e-91
6.576532617242853e-90
7.610102609687114e-89
8.676061489021745e-88
9.745252849369077e-87
1.078454402117796e-85
1.1758405912066996e-84
1.2630859096932532e-83
1.3367645063359865e-82
1.3938443607755822e-81
1.4318937861428266e-80
1.449253145758513e-79
1.4451549000108416e-78
1.4197798872680754e-77
0.2418961213169546
29.996587013377187
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
30.0
29.996587013377187
0.2418961213169546
1.4991116428805962e-37
7.09593774142861e-37
3.309030942062574e-36
1.520219541184936e-35
6.880586516038982e-35
3.068014929709199e-34
1.3477256847126815e-33
5.832526393685685e-33
2.486693210558521e-32
1.0444726745871608e-31
4.32195654392927e-31
1.7618591269494974e-30
7.075684138669341e-30
2.7994398197665187e-29
1.0911343058990557e-28
4.18975436260889e-28
1.5848970376041243e-27
5.906286522007786e-27
2.16834169296565e-26
7.842227284587854e-26
2.794141042904383e-25
9.80738160773965e-25
3.391191224571326e-24
1.1551662656770964e-23
3.876396847119168e-23
1.2814507805887118e-22
4.1731498818709e-22
1.3387908031147398e-21
4.2310329480534504e-21
1.317234940760227e-20
4.0398138371418646e-20
1.220502829527552e-19
3.632398862135388e-19
1.064935892665098e-18
3.075575522453953e-18
8.749838469250917e-18
2.4521215714963397e-17
-6.646468896442241e-13
-6.492908588293843e-13
-1.2790797227773436e-12
-3.1380860653137063e-12
-8.034184193826569e-12
-2.1299957117075116e-11
-5.388233288862089e-11
-1.336700889730223e-10
-3.259624929413722e-10
-7.848195108253327e-10
-1.862356501726464e-09
-4.351827655384214e-09
-1.0015928199182324e-08
-2.2707102724315948e-08
-5.0700930919721016e-08
-1.1149665874237174e-07
-2.414866988375981e-07
-5.151127320553465e-07
-1.0821353900156805e-06
-2.238836863705453e-06
-4.561590382423426e-06
-9.152788442715428e-06
-1.8085222176942212e-05
-3.518978185853949e-05
-6.742481889085898e-05
-0.0001272098556891641
-0.00023632284176821244
-0.0004322745506023445
-0.000778514009044628
-0.001380410671886001
-0.0024097166847313646
-0.004141124775956328
-0.007005547529764231
-0.011665724401468312
-0.019120486599228104
-0.030844123515256047
-0.04896630339437285
-0.07649527955091989
-0.11758195374529357
-0.17781408626199557
-0.2645180504707112
-0.38702997738115286
-0.5568795971602777
-0.7878102500739048
-1.0955403381800757
-1.4971591462483518
-2.010048780474864
-2.6502397621322813
-3.4301458905761857
-4.355687842831922
-5.422904705325763
-6.614263475125245
-7.894997887667614
-9.209923092577029
-10.481260212027562
-11.60804089637524
-12.467624502335086
-12.919733779190596
-12.813194617301495
-11.995262373211105
-10.3230594145218
-7.6762794608347855
-3.9699887833568037
0.833869524573667
6.717732508727897
13.603781944746341
21.354446613045873
29.778577471609246
38.64320746846566
47.69013005572814
56.655887531590544
65.29324260300382
73.39189226052191
80.79612616456768
87.41735366182569
93.23990969399314
98.319251352707
102.77249666064472
106.76213950987052
110.47459603963351
114.09589835978076
117.78726645219496
121.77820584947385
126.81297770576012
133.0045385429884
140.29214700205156
148.55559219162413
157.61971808021588
167.26390796625003
177.23577060956427
187.2677156429852
197.0946980785962
206.47121773156698
215.1857166763416
223.07082655884622
230.00843969924105
235.92924291402568
240.69226076383399
243.61219754098408
244.5840931308387
243.61219754098425
240.6922607638342
235.92924291402588
230.00843969924176
223.0708265588465
215.1857166763403
206.47121773156542
197.09469807859367
187.2677156429811
177.23577060955387
167.26390796622428
157.61971808015065
148.55559219146227
140.29214700165983
133.00453854204636
126.81297770352502
121.77820584425163
117.78726644017503
114.09589833253311
110.47459597879198
106.76213937607314
102.77249637086088
98.31925073457111
93.23990839543097
87.41735097522005
80.79612069066059
73.39188127717568
65.29322090073721
56.65584530385812
47.69004914595627
38.64305481666238
29.778293884265516
21.35392788374684
13.602847728326196
6.7160760168655775
0.8309778667862666
-3.974958127862838
-7.684686105851271
-10.337058256552226
-12.018206896287737
-12.850207431725574
-12.978493053478145
-12.55941821966023
-11.749137942305966
-10.69463442893678
-9.527339279231082
-8.35942287717691
-7.282497289450273
-6.368234777675518
-5.6702553388715335
-5.226584214272908
-5.062014711353521
-5.189817765730215
-5.612399998519431
-6.320709257712384
-7.292404238677017
-8.48902642279672
-9.85262079756485
-11.302426919528648
-12.732381817615682
-14.010218214972888
-14.978885408061565
-15.460853121094285
-15.26558061834486
-14.200061117540436
-12.081920835168315
-8.75411591101726
-4.099893850473491
1.9435637851594059
9.374466095844483
18.120198143387082
28.03667861032978
38.91382562834824
50.48696260457547
62.4532575615877
74.49162383673196
86.28401986402537
97.53585238002086
107.99325570494959
117.45538988670901
125.7805268118845
132.88549123664862
138.73888256899136
143.34930379517664
146.75045582780177
148.98533311979887
150.0918300961071
150.22959974071833
150.22959974320662
150.22959974428304
150.22959974474549
150.22959974493602
150.0918301060705
148.9853331424852
146.75045587849493
143.34930390667196
138.7388828104758
132.8854917517599
125.78052789402058
117.45539212554628
107.99326026653976
97.5358615328099
86.28403794924472
74.49165902651232
62.453324986406685
50.487089814430284
38.91406195118882
28.037110884877702
18.120976657391658
9.375846506516229
1.9459735018422908
-4.0957527257009385
-8.747110363491242
-12.07025511076842
-14.18094063094336
-15.234736494832282
-15.411886817703513
-14.902390128513865
-13.892636261228466
-12.554567731357142
-11.03790886905787
-9.465590820184673
-7.93214682564197
-6.504593988601215
-5.2251689195315265
-4.115240852273905
-3.1797689852556488
-2.4117749492211082
-1.7964383236965809
-1.314567496040162
-0.945330072354551
-0.6682338143237221
-0.46442498951035915
-0.31741618665436455
-0.21337421690837743
-0.14109704593162853
0.15010240399374486
29.937827739089762
29.962987185579564
29.977055476925194
29.986001157969202
29.99159335498682
29.99503065549435
29.99710834221566
29.99834350813736
29.999065783582523
29.99948127070067
29.999716412654955
29.99984734819933
29.999919090225895
29.99995777226701
29.999978297737265
29.999989016650716
29.999994526084507
29.999997313389187
29.999998701431213
30.24189550317524
59.99658672358842
59.99999986619598
59.999999939151564
59.999999972745066
59.99999998797361
59.99999999477203
59.99999999775797
59.99999999904965
59.999999999604626
59.999999999833236
59.999999999929
59.99999999996809
59.99999999998371
59.99999999998957
59.99999999999154
59.999999999988276
59.99999999998697
59.99999999998697
59.99999999998697
59.99999999998697
59.99999999999023
59.999999999993484
59.999999999993484
59.999999999993484
59.99658701337067
30.24189612131044
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999993484
29.999999999996746
30.0
30.0
30.0
29.996587013377187
0.2418961213169546
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
-3.916866830877552e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-7.820410985459603e-12
-3.90354415458205e-12
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
6.517009154549669e-12
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.9539925233402755e-11
2.6045832157706172e-11
2.6045832157706172e-11
2.6045832157706172e-11
2.6045832157706172e-11
2.6045832157706172e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11
1.3022916078853086e-11

upper bound distances
-110.0
-91.667
-73.333
-55.0
-36.667
-18.333
0.0
18.333
36.667
55.0
73.333
91.667
110.0
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 22, 2022, 07:55:50 PM
Well, I think I found another bug. The bad news is I am not sure this would be sufficient to generate negative dps, so it may not be the only one. See how it goes. I highly recommend generating graphical output as it is not easy to assess smoothness from numbers. Anyway


def weapon_adjustment_distance(
        weapon_facing: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float) -> float:
    """
   
    weapon_facing -
    minimum_mean -
    maximum_mean -
    distance -
    """
    #segment from the old angle plus the new angle plus the weapon's
    #angle.
    angle_difference = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    #location of the weapon's mean as it tries to track the target
    #from transformed_angle
    return angle_difference / 180 * math.pi * distance


If this is meant to be the part that calculates the ship's distance from the distribution mean - which I think it is - then shouldn't the calculation be "arc corresponding to ship angle minus distribution mean"?

Compare to mine:

transform_hit_coord <- function(angle, weapon) return(max(weapon$minmean, min(weapon$maxmean,angle)))

#then calculate the angle between the mean and target by subtraction. We refer to this as "transforming"
#since we are moving from expressing the angle wrt. ship to wrt. weapon.
transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))



(Mine is in angles, but note we are doing angle ie. target's angle relative to us minus angle of distribution relative to us)

Edit: to clarify, it seems like you are using the weapon's facing when you should be using two angles here. Ie transformed angle should be ship angle relative to us minus angle of weapon distribution mean relative to us, while, if I understand your code, you currently calculate weapon facing minus max(minmean,min(maxmean, weapon facing)) of weapon facing when calling the transformed angle...?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 22, 2022, 08:35:46 PM
I suspect you have read an old version of the code because the last one I have posted does it your way.  Regardless, here is a new version without the function weapon_adjustment_distance.  Graphical output closely corresponds to yours.

Code
Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
               
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    if b > 0: return (f(x, NormalDist(0, standard_deviation),
                        uniform_distribution_radius) if a > 0
                      else max(0, min(1, b / 2 + x)))
    return 0 if x < 0 else 1


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Hit distribution function.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[j] + spread) / double_spread)
                        - min(1, max(0, bounds[j-1] + spread) / double_spread)
                        for i, bound in enumerate(bounds[1:]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bound) - cdf(bounds[i-1]) for i, bound in
                        enumerate(bounds[1:]))
                + ((1 - cdf(bounds[-1])),))
    numbers = standard_deviation, spread
    return ((probability_hit_before_bound(bounds[0], *numbers),)
            + tuple(probability_hit_before_bound(bound, *numbers)
                    - probability_hit_before_bound(bounds[i-1], *numbers)
                    for i, bound in enumerate(bounds[1:]))
            + ((1 - probability_hit_before_bound(bounds[-1], *numbers)),))
   

def hit_distribution_at_optimum_angle(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float,
        bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_angle = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    adjustment_distance = arc_length(adjustment_angle, distance)
    adjusted_bounds = tuple(bound + adjustment_distance for bound in bounds)
    return hit_distribution(adjusted_bounds, error_distance, spread_distance)


def main():
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
    distance = 1000
    cell_count = 12
    target_radius = 220#(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    target_arc_measure = arc_measure(target_radius, distance)
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        #we have defined spread in degrees, so we must convert it to
        #pixels to be consistent
        half_difference = (weapon["arc"] - weapon["spread"]) / 2
        minimum_mean = weapon["facing"] - half_difference
        maximum_mean = weapon["facing"] + half_difference
        weapon["minimum mean"] = min(minimum_mean, maximum_mean)
        weapon["maximum mean"] = max(minimum_mean, maximum_mean)
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        print("spread angle", weapon["spread"])
        print("spread distance", round(weapon["spread distance"]))
        print("minimum mean", weapon["minimum mean"])
        print("maximum mean", weapon["maximum mean"])
        print()
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_totals_expected = []
    for target_facing in range(-359,361):
        damage_per_second_total_expected = 0
        for weapon in weapons:
            weapon_facing = transformed_angle(target_facing,
                                              weapon["minimum mean"],
                                              weapon["maximum mean"])
            weapon_arc = arc_length(weapon_facing, distance)
            lower_bound = arc_length(weapon_arc - target_radius, distance)
            upper_bound = arc_length(weapon_arc + target_radius, distance)
            probability = (
                probability_hit_before_bound(upper_bound, error_distance,
                                             weapon["spread distance"])
                - probability_hit_before_bound(lower_bound, error_distance,
                                               weapon["spread distance"]))
            damage_per_second_total_expected += weapon["damage"] * probability
        damage_per_second_totals_expected.append(
            damage_per_second_total_expected)

    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_totals_expected[i+360] += (
            damage_per_second_totals_expected[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720):
        damage_per_second_totals_expected[i-360] += (
            damage_per_second_totals_expected[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_totals_expected = (
        damage_per_second_totals_expected[181:540])
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
   
    #the optimum angle is the midmost maximum
    damage_per_second_maximum = 0
    damage_per_second_maximum_index = 0
    for i, damage_per_second in enumerate(damage_per_second_totals_expected):
        if damage_per_second > damage_per_second_maximum:
            damage_per_second_maximum_index = i
    x_axis = tuple(i for i in range(-179,180))
    optimum_angle = x_axis[damage_per_second_maximum_index]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    bound_distances = tuple(angle_range * 2 * math.pi * distance
                            for angle_range in angle_ranges)
    print("bound distances")
    for bound_distance in bound_distances:
        print(round(bound_distance, 3))
    print()
    #print results
    print("hit distribution at optimum angle")
    for i, weapon in enumerate(weapons):
        print("weapon", i)
        print(hit_distribution_at_optimum_angle(weapon["facing"],
                                                weapon["spread distance"],
                                                error_distance,
                                                weapon["minimum mean"],
                                                weapon["maximum mean"],
                                                distance,
                                                bound_distances))
main()
[close]
Result
spread angle 5
spread distance 87
minimum mean -17.5
maximum mean -2.5

spread angle 5
spread distance 87
minimum mean 2.5
maximum mean 17.5

spread angle 0
spread distance 0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87
minimum mean 87.5
maximum mean 92.5

bound distances
-110.0
-91.667
-73.333
-55.0
-36.667
-18.333
0.0
18.333
36.667
55.0
73.333
91.667
110.0

hit distribution at optimum angle
weapon 0
(0.46493696976183185, -0.06428797965951033, 0.011679301207214443, 0.01168495335116726, 0.011689350881235405, 0.011692492734030946, 0.011694378149718243, 0.01169500667228801, 0.011694378149718354, 0.011692492734030835, 0.011689350881235572, 0.01168495335116726, 0.011679301207214277, 0.46493696976183185)
weapon 1
(0.46493696976183185, -0.06428797965951033, 0.011679301207214443, 0.01168495335116726, 0.011689350881235405, 0.011692492734030946, 0.011694378149718243, 0.01169500667228801, 0.011694378149718354, 0.011692492734030835, 0.011689350881235572, 0.01168495335116726, 0.011679301207214277, 0.46493696976183185)
weapon 2
(0.01390344751349859, -0.9527200449016842, 0.057329929900487575, 0.10228955336156548, 0.1604441972208122, 0.221267775791116, 0.26832242536520173, 0.2861323265250028, 0.2683224253652018, 0.22126777579111578, 0.16044419722081205, 0.10228955336156553, 0.057329929900487464, 0.01390344751349859)
weapon 3
(0.01390344751349859, -0.9527200449016842, 0.057329929900487575, 0.10228955336156548, 0.1604441972208122, 0.221267775791116, 0.26832242536520173, 0.2861323265250028, 0.2683224253652018, 0.22126777579111578, 0.16044419722081205, 0.10228955336156553, 0.057329929900487464, 0.01390344751349859)
weapon 4
(0.01390344751349859, -0.9527200449016842, 0.057329929900487575, 0.10228955336156548, 0.1604441972208122, 0.221267775791116, 0.26832242536520173, 0.2861323265250028, 0.2683224253652018, 0.22126777579111578, 0.16044419722081205, 0.10228955336156553, 0.057329929900487464, 0.01390344751349859)
weapon 5
(0.46493696976183185, -0.06428797965951033, 0.011679301207214443, 0.01168495335116726, 0.011689350881235405, 0.011692492734030946, 0.011694378149718243, 0.01169500667228801, 0.011694378149718354, 0.011692492734030835, 0.011689350881235572, 0.01168495335116726, 0.011679301207214277, 0.46493696976183185)
[close]

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 22, 2022, 09:21:48 PM
I'm a little confused as the numbers don't seem like mine. What does the graph look like?

Anyway there still seem to be some issues that need fixing as for example there is a -95% probability for weapon 3 to hit ship middle cell 1. I wonder if this is right?


adjustment_angle = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)


In

def hit_distribution_at_optimum_angle(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float,
        bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_angle = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    adjustment_distance = arc_length(adjustment_angle, distance)
    adjusted_bounds = tuple(bound + adjustment_distance for bound in bounds)
    return hit_distribution(adjusted_bounds, error_distance, spread_distance)


With the same reasoning as above. I think there must be something wrong with the probability calculation itself though, since it should never produce negative numbers when the calculation is correct since we are taking an integral in the positive direction over a strictly positive function.

I'm confused by this part. Let me comment what I think it does.

damage_per_second_total_expected = 0
        for weapon in weapons:
# calculate angle of target to dist mean - correct
            weapon_facing = transformed_angle(target_facing,
                                              weapon["minimum mean"],
                                              weapon["maximum mean"])
#convert it to an arc length
            weapon_arc = arc_length(weapon_facing, distance)
#take arc length of arc length - target radius?
            lower_bound = arc_length(weapon_arc - target_radius, distance)
# same with + radius
            upper_bound = arc_length(weapon_arc + target_radius, distance)
            probability = (
                probability_hit_before_bound(upper_bound, error_distance,
                                             weapon["spread distance"])
                - probability_hit_before_bound(lower_bound, error_distance,
                                               weapon["spread distance"]))
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 22, 2022, 10:13:44 PM
I think the calculations here are relatively simple but it's very easy to get confused about the shifts in coordinates and signed angles and whatnot, leading to potentially no end of problems, so I tried to create a visual guide to what we're calculating here using MSPaint.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 23, 2022, 09:05:37 AM
My graph somewhat resembles yours: a very high flat-top peak on the left, followed by a sharp, shouldered peak, followed by a lower round-top peak, followed by a peak with a flat top but a single narrow pair of shoulder points high above with a single peak point high above them.  It has small negative probability valleys around the shouldered peak, and many of the probabilities near zero are very slightly negative or positive. 

On my graph, I have traced the source of negative probability  to the g helper function of my probability_hit_before_bound function and found that x was never greater than 0 when the probability ultimately determined by probability_hit_before_bound was less than 0 but that x could be less than 0 without the probability being less than 0.

In the hit_distribution function, I have found that an iteration error caused the negative probabilities.  It now yields a positive hit distribution.

Code
Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        c = normal_distribution.stdev / (2 * uniform_distribution_parameter)
        d = g(a + b, normal_distribution) - g(a - b, normal_distribution)
        p = c * d
        return p
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        p = (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
        return p
    #if b > 0: return (f(x, NormalDist(0, standard_deviation),
    #                    uniform_distribution_radius) if a > 0
    #                  else max(0, min(1, b / 2 + x)))
    #return 0 if x < 0 else 1


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Return probability to hit between each pair of bounds.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[i+1] + spread) / double_spread)
                        - min(1, max(0, bounds[i] + spread) / double_spread)
                        for i, bound in enumerate(bounds[:-1]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[i+1]) - cdf(bound) for i, bound in
                        enumerate(bounds[:-1]))
                + ((1 - cdf(bounds[-1])),))
    numbers = standard_deviation, spread
    return ((probability_hit_before_bound(bounds[0], *numbers),)
            + tuple(probability_hit_before_bound(bounds[i+1], *numbers)
                    - probability_hit_before_bound(bound, *numbers)
                    for i, bound in enumerate(bounds[:-1]))
            + ((1 - probability_hit_before_bound(bounds[-1], *numbers)),))
   

def hit_distribution_at_optimum_angle(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float,
        bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_angle = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    adjustment_distance = arc_length(adjustment_angle, distance)
    adjusted_bounds = tuple(bound + adjustment_distance for bound in bounds)
    return hit_distribution(adjusted_bounds, error_distance, spread_distance)


def main():
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
    distance = 1000
    cell_count = 12
    target_radius = 220#(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    target_arc_measure = arc_measure(target_radius, distance)
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        #we have defined spread in degrees, so we must convert it to
        #pixels to be consistent
        half_difference = (weapon["arc"] - weapon["spread"]) / 2
        minimum_mean = weapon["facing"] - half_difference
        maximum_mean = weapon["facing"] + half_difference
        weapon["minimum mean"] = min(minimum_mean, maximum_mean)
        weapon["maximum mean"] = max(minimum_mean, maximum_mean)
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        #print("spread angle", weapon["spread"])
        #print("spread distance", round(weapon["spread distance"]))
        #print("minimum mean", weapon["minimum mean"])
        #print("maximum mean", weapon["maximum mean"])
        #print()
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_totals_expected = []
    for target_facing in range(-359,361):
        damage_per_second_total_expected = 0
        for weapon in weapons:
            weapon_facing = transformed_angle(target_facing,
                                              weapon["minimum mean"],
                                              weapon["maximum mean"])
            weapon_arc = arc_length(weapon_facing, distance)
            lower_bound = arc_length(weapon_arc, distance) - target_radius
            upper_bound = arc_length(weapon_arc, distance) + target_radius
            probability = (
                probability_hit_before_bound(upper_bound, error_distance,
                                             weapon["spread distance"])
                - probability_hit_before_bound(lower_bound, error_distance,
                                               weapon["spread distance"]))
            damage_per_second_total_expected += weapon["damage"] * probability
        damage_per_second_totals_expected.append(
            damage_per_second_total_expected)

    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_totals_expected[i+360] += (
            damage_per_second_totals_expected[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720):
        damage_per_second_totals_expected[i-360] += (
            damage_per_second_totals_expected[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_totals_expected = (
        damage_per_second_totals_expected[181:540])
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
   
    #the optimum angle is the midmost maximum
    damage_per_second_maximum = 0
    damage_per_second_maximum_index = 0
    for i, damage_per_second in enumerate(damage_per_second_totals_expected):
        if damage_per_second > damage_per_second_maximum:
            damage_per_second_maximum_index = i
    x_axis = tuple(i for i in range(-179,180))
    optimum_angle = x_axis[damage_per_second_maximum_index]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    bound_distances = tuple(angle_range * 2 * math.pi * distance
                            for angle_range in angle_ranges)
    #print("bound distances")
    #for bound_distance in bound_distances:
        #print(round(bound_distance, 3))
    print()
    #print results
    print("hit distribution at optimum angle")
    for i, weapon in enumerate(weapons):
        print("weapon", i)
        print(hit_distribution_at_optimum_angle(weapon["facing"],
                                                weapon["spread distance"],
                                                error_distance,
                                                weapon["minimum mean"],
                                                weapon["maximum mean"],
                                                distance,
                                                bound_distances))
main()
[close]
Result
hit distribution at optimum angle
weapon 0
(0.46493696976183185, 0.0058380808168259635, 0.00584122039038848, 0.00584373296077878, 0.005845617920456625, 0.005846874813574321, 0.005847503336143922, 0.005847503336144089, 0.005846874813574265, 0.0058456179204565695, 0.005843732960779002, 0.005841220390388258, 0.005838080816826019, 0.46493696976183185)
weapon 1
(0.46493696976183185, 0.0058380808168259635, 0.00584122039038848, 0.00584373296077878, 0.005845617920456625, 0.005846874813574321, 0.005847503336143922, 0.005847503336144089, 0.005846874813574265, 0.0058456179204565695, 0.005843732960779002, 0.005841220390388258, 0.005838080816826019, 0.46493696976183185)
weapon 2
(0.01390344751349859, 0.01947306007131866, 0.037856869829168915, 0.06443268353239656, 0.09601151368841565, 0.12525626210270036, 0.14306616326250138, 0.14306616326250143, 0.12525626210270036, 0.09601151368841543, 0.06443268353239662, 0.037856869829168915, 0.01947306007131855, 0.01390344751349859)
weapon 3
(0.01390344751349859, 0.01947306007131866, 0.037856869829168915, 0.06443268353239656, 0.09601151368841565, 0.12525626210270036, 0.14306616326250138, 0.14306616326250143, 0.12525626210270036, 0.09601151368841543, 0.06443268353239662, 0.037856869829168915, 0.01947306007131855, 0.01390344751349859)
weapon 4
(0.01390344751349859, 0.01947306007131866, 0.037856869829168915, 0.06443268353239656, 0.09601151368841565, 0.12525626210270036, 0.14306616326250138, 0.14306616326250143, 0.12525626210270036, 0.09601151368841543, 0.06443268353239662, 0.037856869829168915, 0.01947306007131855, 0.01390344751349859)
weapon 5
(0.46493696976183185, 0.0058380808168259635, 0.00584122039038848, 0.00584373296077878, 0.005845617920456625, 0.005846874813574321, 0.005847503336143922, 0.005847503336144089, 0.005846874813574265, 0.0058456179204565695, 0.005843732960779002, 0.005841220390388258, 0.005838080816826019, 0.46493696976183185)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 23, 2022, 09:56:54 AM
Great, making progress! The hit distributions are still quite off. Here are the correct ones from before:


> #print results
> hit_distribution_at_optimum_angle(weapons[1,])
 [1] 0.118751068 0.077981550 0.102383867 0.121027732 0.129001152 0.124055991 0.107596198 0.084057103
 [9] 0.059030566 0.037172868 0.020934021 0.010514600 0.004698446 0.002794840
> hit_distribution_at_optimum_angle(weapons[2,])
 [1] 0.002794840 0.004698446 0.010514600 0.020934021 0.037172868 0.059030566 0.084057103 0.107596198
 [9] 0.124055991 0.129001152 0.121027732 0.102383867 0.077981550 0.118751068
> hit_distribution_at_optimum_angle(weapons[3,])
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
> hit_distribution_at_optimum_angle(weapons[4,])
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
> hit_distribution_at_optimum_angle(weapons[5,])
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
> hit_distribution_at_optimum_angle(weapons[6,])
 [1] 8.351879e-250 1.799421e-244 3.390000e-239 5.584509e-234 8.044357e-229 1.013261e-223 1.116033e-218
 [8] 1.074884e-213 9.052691e-209 6.666963e-204 4.293542e-199 2.417929e-194 1.190735e-189  1.000000e+00


Even if there were some kind of an error in my code, we know the real ones must look something like this since the first two guns are symmetrically reaching for a target just a little outside their ability to align the probability distribution, and the others are far out of range.

By contrast, your guns 2 to 5 can hit and 0 to 1 have a flat distribution. Was there anything to what I commented above about calculating the arc corresponding to an arc?

Also, is this fixed?
Result
spread angle 5
spread distance 87
minimum mean -17.5
maximum mean -2.5

spread angle 5
spread distance 87
minimum mean 2.5
maximum mean 17.5

spread angle 0
spread distance 0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87
minimum mean 87.5
maximum mean 92.5


[close]
Specifically, about weapon 5: when it is the case that spread is greater than or equal to tracking angle (or tracking angle is 0 which is a subset of this) then it must be min mean = max mean = facing since the weapon cannot track. In that print it seems like weapon 5 can track over 5 degrees yet has an arc of 0. This must be special cased for all weapons with spread >= tracking arc.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 23, 2022, 02:25:41 PM
Great, making progress! The hit distributions are still quite off. Here are the correct ones from before:


> #print results
> hit_distribution_at_optimum_angle(weapons[1,])
 [1] 0.118751068 0.077981550 0.102383867 0.121027732 0.129001152 0.124055991 0.107596198 0.084057103
 [9] 0.059030566 0.037172868 0.020934021 0.010514600 0.004698446 0.002794840
> hit_distribution_at_optimum_angle(weapons[2,])
 [1] 0.002794840 0.004698446 0.010514600 0.020934021 0.037172868 0.059030566 0.084057103 0.107596198
 [9] 0.124055991 0.129001152 0.121027732 0.102383867 0.077981550 0.118751068
> hit_distribution_at_optimum_angle(weapons[3,])
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
> hit_distribution_at_optimum_angle(weapons[4,])
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
> hit_distribution_at_optimum_angle(weapons[5,])
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
> hit_distribution_at_optimum_angle(weapons[6,])
 [1] 8.351879e-250 1.799421e-244 3.390000e-239 5.584509e-234 8.044357e-229 1.013261e-223 1.116033e-218
 [8] 1.074884e-213 9.052691e-209 6.666963e-204 4.293542e-199 2.417929e-194 1.190735e-189  1.000000e+00


Even if there were some kind of an error in my code, we know the real ones must look something like this since the first two guns are symmetrically reaching for a target just a little outside their ability to align the probability distribution, and the others are far out of range.

I definitely don't have those distributions.  The hit_distribution function of my code cannot return any of the three one-and-zeros distributions shown unless the standard deviation (declared as error_distance in the main method) is 0, and the standard deviation is not zero.

Code
def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Return probability to hit between each pair of bounds.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[i+1] + spread) / double_spread)
                        - min(1, max(0, bounds[i] + spread) / double_spread)
                        for i, bound in enumerate(bounds[:-1]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[i+1]) - cdf(bound) for i, bound in
                        enumerate(bounds[:-1]))
                + ((1 - cdf(bounds[-1])),))
    numbers = standard_deviation, spread
    return ((probability_hit_before_bound(bounds[0], *numbers),)
            + tuple(probability_hit_before_bound(bounds[i+1], *numbers)
                    - probability_hit_before_bound(bound, *numbers)
                    for i, bound in enumerate(bounds[:-1]))
            + ((1 - probability_hit_before_bound(bounds[-1], *numbers)),))
Code
def main():
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
    ...

Also, my hit_probability_before_bound function does not cover cases where standard deviation is zero, but we have a non-zero one, so I suppose that's alright.

Quote
By contrast, your guns 2 to 5 can hit and 0 to 1 have a flat distribution. Was there anything to what I commented above about calculating the arc corresponding to an arc?

Would you please rephrase both of these sentences?  I don't understand them.

Also, is this fixed?
Result
spread angle 5
spread distance 87
minimum mean -17.5
maximum mean -2.5

spread angle 5
spread distance 87
minimum mean 2.5
maximum mean 17.5

spread angle 0
spread distance 0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87
minimum mean 87.5
maximum mean 92.5

[close]
Specifically, about weapon 5: when it is the case that spread is greater than or equal to tracking angle (or tracking angle is 0 which is a subset of this) then it must be min mean = max mean = facing since the weapon cannot track. In that print it seems like weapon 5 can track over 5 degrees yet has an arc of 0. This must be special cased for all weapons with spread >= tracking arc.
[/quote]

I have now fixed that.

Code
    for weapon in weapons:
        #we have defined spread in degrees, so we must convert it to
        #pixels to be consistent
        if weapon["spread"] >= weapon["arc"] or weapon["arc"] == 0:
            minimum_mean = weapon["facing"]
            maximum_mean = weapon["facing"]
        else:
            half_difference = (weapon["arc"] - weapon["spread"]) / 2
            minimum_mean = weapon["facing"] - half_difference
            maximum_mean = weapon["facing"] + half_difference

Code
Code
"""
Calculate the optimum angle to place the enemy ship relative to ours.


Assume
- Angle exactly to the right is 0 degrees
- Possible angles are integers from -179 to 180
- Our ship is:
  - heading towards +90 degrees
  - pointlike.
- The enemy ship is:
  - at a constant range
  - a straight line of hittable armor cells
  - oriented tangentially to a circle defined by our ship's position
    and range.
- The ships are so far apart that the arc across that circle, from one
  point on the enemy ship to the other, approximates the secant between
  those points.


Weapon Arcs
A weapon cannot exceed its maximum turn angle and has constant spread;
therefore, the maximum mean points of the probability distribution of a
weapon are

{ maximum turn angle - spread / 2, minimum turn angle + spread/2 }

A weapon tracking a target tries to align the mean of the probability
distribution with the target. Therefore we can find the signed angle
of the target from the mean of the distribution by computing the
following in this order:

1. The mean point, that is, max mean, when target is above max mean
2. target, when target is between max mean
3. min mean, min mean, when target is below min mean.
4. Minimum and maximum means for the weapon and find the median of
its hit distribution ("transform hit coord" for coordinates of mean
of modified dist in terms of the original distribution)


Arcs vs Widths
Referring to the width of the enemy ship in pixels but to the placement
and tracking ability of turrets as angles is convenient. Therefore, we
must convert between the two. Note that we assume we can use the arc
to describe the enemy ship.


Methods
"""
import math
from statistics import NormalDist


def probability_hit_before_bound(
        x: float,
        standard_deviation: float,
        uniform_distribution_radius: float) -> float:
    """
    Return the probability that the hit coordinate is less than x.
   
    This probability equals the integral, from -inf to x, of the hit
    probability distribution, which is
   
    - normal if the parameter of the uniform distribution is 0
    - uniform if the standard deviation of the normal distribution is 0
    - trivial if both are 0, the hit distribution is trivial: the CDF
      equals a step function going from 0 to 1 where x = 0.
    - a normal distribution convolved with a uniform one if both exceed 0
   
    x - real number
    standard_deviation - standard deviation of normal distribution
    uniform_distribution_radius - radius of uniform distribution
    """
    def g(x: float, normal_distribution: object) -> float:
        """
        Return the cumulative distribution function of the convolved
        normal distribution.
       
        x - real number
        normal_distribution - a Python standard library NormalDist
        """
        return x * normal_distribution.cdf(x) + normal_distribution.pdf(x)

    def f(x: float,
          normal_distribution: float,
          uniform_distribution_parameter: float) -> float:
        a = x / normal_distribution.stdev
        b = uniform_distribution_parameter / normal_distribution.stdev
        return (normal_distribution.stdev
                / (2 * uniform_distribution_parameter)
                * (g(a + b, normal_distribution)
                   - g(a - b, normal_distribution)))
    if standard_deviation > 0:
        normal_distribution = NormalDist(0, standard_deviation)
        return (f(x, normal_distribution, uniform_distribution_radius)
                if uniform_distribution_radius > 0
                else normal_distribution.cdf(x))
    #if b > 0: return (f(x, NormalDist(0, standard_deviation),
    #                    uniform_distribution_radius) if a > 0
    #                  else max(0, min(1, b / 2 + x)))
    #return 0 if x < 0 else 1


def arc_measure(arc_length: float, radius: float) -> float:
    """
    Return the degree angle of an arc of some radius.
   
    arc -
    radius -
    """
    return arc_length / radius / 180 * math.pi


def arc_length(degrees: float, radius: float) -> float:
    """
    Return the arc of a degree angle of some radius.
   
    degrees -
    radius -
    """
    return degrees * math.pi / 180 * radius


def transformed_hit_coord(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    angle -
    minimum_mean -
    maximum_mean -
    """
    return max(minimum_mean, min(maximum_mean, angle))


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle between the mean and target.
   
    We refer to this as "transforming" since we are moving from
    expressing the angle wrt. ship to wrt. weapon.
   
    angle -
    minimum_mean -
    maximum_mean -
    """
    return angle - transformed_hit_coord(angle, minimum_mean, maximum_mean)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Return probability to hit between each pair of bounds.
   
    bounds -
    standard_deviation -
    spread -
    """
    if standard_deviation == 0:
        if spread == 0:
            #all shots hit 1 cell, even if the ship has evenly many
            #cells, to prevent such ships from appearing tougher
            return 0, + tuple(1 if bounds[i-1] < 0 <= bound else 0
                              for i, bound in enumerate(bounds[1:]))
        double_spread = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / double_spread),
                + tuple(min(1, max(0, bounds[i+1] + spread) / double_spread)
                        - min(1, max(0, bounds[i] + spread) / double_spread)
                        for i, bound in enumerate(bounds[:-1]))
                + (1 - min(1, max(0, bounds[-1] + spread) / double_spread)),)
    elif spread == 0:
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[i+1]) - cdf(bound) for i, bound in
                        enumerate(bounds[:-1]))
                + ((1 - cdf(bounds[-1])),))
    numbers = standard_deviation, spread
    return ((probability_hit_before_bound(bounds[0], *numbers),)
            + tuple(probability_hit_before_bound(bounds[i+1], *numbers)
                    - probability_hit_before_bound(bound, *numbers)
                    for i, bound in enumerate(bounds[:-1]))
            + ((1 - probability_hit_before_bound(bounds[-1], *numbers)),))
   

def hit_distribution_at_optimum_angle(
        weapon_facing: float,
        spread_distance: float,
        error_distance: float,
        minimum_mean: float,
        maximum_mean: float,
        distance: float,
        bounds: tuple) -> tuple:
    """
   
    spread_distance -
    weapon_facing -
    minimum_mean -
    maximum_mean -
    upper_bounds -
    """
    adjustment_angle = transformed_angle(weapon_facing, minimum_mean,
                                         maximum_mean)
    adjustment_distance = arc_length(adjustment_angle, distance)
    adjusted_bounds = tuple(bound + adjustment_distance for bound in bounds)
    return hit_distribution(adjusted_bounds, error_distance, spread_distance)


def main():
    #same standard deviation as in other modules
    error_standard_deviation = 0.05
    error_distance = error_standard_deviation * distance
   
    #Test against a Dominator. We wish to integrate this code
    #with the other modules eventually, so we will use the full ship
    #definition, even though only the width is needed.
    distance = 1000
    cell_count = 12
    target_radius = 220#(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)
    target_arc_measure = arc_measure(target_radius, distance)
   
    #two weapons slightly angled from each other, arcs overlapping
    #in the middle, and rear-mounted point defense weapons to test
    #wraparound, extreme angles
    weapons = (
        {"damage" : 100, "facing" : -10, "arc" : 20, "spread" : 5},
        {"damage" : 100, "facing" : 10, "arc" : 20, "spread" : 5},
        {"damage" : 30, "facing" : -160, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 180, "arc" : 20, "spread" : 0},
        {"damage" : 30, "facing" : 160, "arc" : 20, "spread" : 0},
        {"damage" : 120, "facing" : 90, "arc" : 0, "spread" : 5}
    )
    for weapon in weapons:
        #we have defined spread in degrees, so we must convert it to
        #pixels to be consistent
        if weapon["spread"] >= weapon["arc"] or weapon["arc"] == 0:
            minimum_mean = weapon["facing"]
            maximum_mean = weapon["facing"]
        else:
            half_difference = (weapon["arc"] - weapon["spread"]) / 2
            minimum_mean = weapon["facing"] - half_difference
            maximum_mean = weapon["facing"] + half_difference
        weapon["minimum mean"] = min(minimum_mean, maximum_mean)
        weapon["maximum mean"] = max(minimum_mean, maximum_mean)
        weapon["spread distance"] = arc_length(weapon["spread"], distance)
        print("spread angle", weapon["spread"])
        print("spread distance", round(weapon["spread distance"]))
        print("minimum mean", weapon["minimum mean"])
        print("maximum mean", weapon["maximum mean"])
        print()
   
    #Map signed to unsigned angles
   
    #Define a vector from -360 to 360, encompassing all possible
    #firing angles because a weapon can face from -180 to 180
    #degrees off the ship's facing and can track at most 360
    #degrees total.
    damage_per_second_totals_expected = []
    for target_facing in range(-359,361):
        damage_per_second_total_expected = 0
        for weapon in weapons:
            weapon_facing = transformed_angle(target_facing,
                                              weapon["minimum mean"],
                                              weapon["maximum mean"])
            weapon_arc = arc_length(weapon_facing, distance)
            lower_bound = arc_length(weapon_arc, distance) - target_radius
            upper_bound = arc_length(weapon_arc, distance) + target_radius
            probability = (
                probability_hit_before_bound(upper_bound, error_distance,
                                             weapon["spread distance"])
                - probability_hit_before_bound(lower_bound, error_distance,
                                               weapon["spread distance"]))
            damage_per_second_total_expected += weapon["damage"] * probability
        damage_per_second_totals_expected.append(
            damage_per_second_total_expected)

    #-180 corresponds to +180
    #-181 corresponds to +179 etc, so 360 + index for indices 1:180
    for i in range(180):
        damage_per_second_totals_expected[i+360] += (
            damage_per_second_totals_expected[i])
   
    #and +360 corresponds to 0, +359 to -1 etc.
    #so index - 360 for indices 540:720
    for i in range(540, 720):
        damage_per_second_totals_expected[i-360] += (
            damage_per_second_totals_expected[i])
   
    #finally, to get angles -179 to 180, select indices 181 to 540
    #of the new vector.
    damage_per_second_totals_expected = (
        damage_per_second_totals_expected[181:540])
   
    #note that vector indices no longer correspond to angles, rather
    #vector index 1 corresponds to -179. to get a correct plot add
   
    #the optimum angle is the midmost maximum
    damage_per_second_maximum = 0
    damage_per_second_maximum_index = 0
    for i, damage_per_second in enumerate(damage_per_second_totals_expected):
        if damage_per_second > damage_per_second_maximum:
            damage_per_second_maximum_index = i
    x_axis = tuple(i for i in range(-179,180))
    optimum_angle = x_axis[damage_per_second_maximum_index]
   
    #plot the result
    #plot(dps_at_angles,x=xaxis)
    #abline(v=optimumangle)
   
    #the usual - calculate ship cell upper bound angles
    target_angle = target_radius / (2 * math.pi * distance)
    cell_angle = target_angle / cell_count
    angle_ranges = [-target_angle/2]
    for _ in range(cell_count):
        angle_ranges.append(angle_ranges[-1] + cell_angle)
    #convert to pixels
    bound_distances = tuple(angle_range * 2 * math.pi * distance
                            for angle_range in angle_ranges)
    #print("bound distances")
    #for bound_distance in bound_distances:
        #print(round(bound_distance, 3))
    #print results
    print("hit distribution at optimum angle")
    for i, weapon in enumerate(weapons):
        print("weapon", i)
        print(hit_distribution_at_optimum_angle(weapon["facing"],
                                                weapon["spread distance"],
                                                error_distance,
                                                weapon["minimum mean"],
                                                weapon["maximum mean"],
                                                distance,
                                                bound_distances))
main()
[close]
Result
spread angle 5
spread distance 87
minimum mean -17.5
maximum mean -2.5

spread angle 5
spread distance 87
minimum mean 2.5
maximum mean 17.5

spread angle 0
spread distance 0
minimum mean -170.0
maximum mean -150.0

spread angle 0
spread distance 0
minimum mean 170.0
maximum mean 190.0

spread angle 0
spread distance 0
minimum mean 150.0
maximum mean 170.0

spread angle 5
spread distance 87
minimum mean 90
maximum mean 90

hit distribution at optimum angle
weapon 0
(0.46493696976183185, 0.0058380808168259635, 0.00584122039038848, 0.00584373296077878, 0.005845617920456625, 0.005846874813574321, 0.005847503336143922, 0.005847503336144089, 0.005846874813574265, 0.0058456179204565695, 0.005843732960779002, 0.005841220390388258, 0.005838080816826019, 0.46493696976183185)
weapon 1
(0.46493696976183185, 0.0058380808168259635, 0.00584122039038848, 0.00584373296077878, 0.005845617920456625, 0.005846874813574321, 0.005847503336143922, 0.005847503336144089, 0.005846874813574265, 0.0058456179204565695, 0.005843732960779002, 0.005841220390388258, 0.005838080816826019, 0.46493696976183185)
weapon 2
(0.01390344751349859, 0.01947306007131866, 0.037856869829168915, 0.06443268353239656, 0.09601151368841565, 0.12525626210270036, 0.14306616326250138, 0.14306616326250143, 0.12525626210270036, 0.09601151368841543, 0.06443268353239662, 0.037856869829168915, 0.01947306007131855, 0.01390344751349859)
weapon 3
(0.01390344751349859, 0.01947306007131866, 0.037856869829168915, 0.06443268353239656, 0.09601151368841565, 0.12525626210270036, 0.14306616326250138, 0.14306616326250143, 0.12525626210270036, 0.09601151368841543, 0.06443268353239662, 0.037856869829168915, 0.01947306007131855, 0.01390344751349859)
weapon 4
(0.01390344751349859, 0.01947306007131866, 0.037856869829168915, 0.06443268353239656, 0.09601151368841565, 0.12525626210270036, 0.14306616326250138, 0.14306616326250143, 0.12525626210270036, 0.09601151368841543, 0.06443268353239662, 0.037856869829168915, 0.01947306007131855, 0.01390344751349859)
weapon 5
(0.46493696976183185, 0.0058380808168259635, 0.00584122039038848, 0.00584373296077878, 0.005845617920456625, 0.005846874813574321, 0.005847503336143922, 0.005847503336144089, 0.005846874813574265, 0.0058456179204565695, 0.005843732960779002, 0.005841220390388258, 0.005838080816826019, 0.46493696976183185)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 23, 2022, 09:00:51 PM
Oh, the 1s come from rounding. A normal pdf or CDF is never completely zero (or 1, for the CDF) due to the definition of the function. R will round things like 1-1*10^-350 to 1, if it even can calculate the very small number at all. For example for a standard normal distribution, the pdf of value 100 is 1/sqrt(2pi)*e^(-5000). You can see this in the other gun where there is a 1*10^-189 chance to hit cell 12 but 100% chance to miss.

I'll rephrase: according to the hit distribution, your guns 0 and 1 seem to have an approximately flat hit distribution, which is not correct, since they have a small spread. Your guns 2 to 5 have a nonzero (and non-minuscule, in fact significant) probability to hit the target despite being located on the opposite side of the ship and only having a 20 degree tracking arc in case of guns 2 to 4 and not being able to track and not being aimed at target in case of gun 5. So these results are not correct. (Additionally, it appears your guns 0 and 1 are identical, when they should be mirror images of each other, and gun 5 is identical to 0 and 1, despite having very different parameters).

It seems like at


weapon_arc = arc_length(weapon_facing, distance)
            lower_bound = arc_length(weapon_arc, distance) - target_radius


You are calculating lower bound as arc length of ( arc length of (weapon facing)) - target radius'when it should probably be arc length of ( weapon facing ) - target radius. So lower_bound = weapon_arc - target_radius . And likewise for upper bound.

Merry Christmas/happy holidays by the way!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 23, 2022, 10:42:12 PM
Oh, the 1s come from rounding. A normal pdf or CDF is never completely zero (or 1, for the CDF) due to the definition of the function. R will round things like 1-1*10^-350 to 1, if it even can calculate the very small number at all. For example for a standard normal distribution, the pdf of value 100 is 1/sqrt(2pi)*e^(-5000). You can see this in the other gun where there is a 1*10^-189 chance to hit cell 12 but 100% chance to miss.

Ah, thanks.  Python does not automatically round floats.

Quote
I'll rephrase: according to the hit distribution, your guns 0 and 1 seem to have an approximately flat hit distribution, which is not correct, since they have a small spread. Your guns 2 to 5 have a nonzero (and non-minuscule, in fact significant) probability to hit the target despite being located on the opposite side of the ship and only having a 20 degree tracking arc in case of guns 2 to 4 and not being able to track and not being aimed at target in case of gun 5. So these results are not correct. (Additionally, it appears your guns 0 and 1 are identical, when they should be mirror images of each other, and gun 5 is identical to 0 and 1, despite having very different parameters).

Thanks, now I understand the error you see.

Quote
It seems like at


weapon_arc = arc_length(weapon_facing, distance)
            lower_bound = arc_length(weapon_arc, distance) - target_radius


You are calculating lower bound as arc length of ( arc length of (weapon facing)) - target radius'when it should probably be arc length of ( weapon facing ) - target radius. So lower_bound = weapon_arc - target_radius . And likewise for upper bound.

Fixing that error only makes the hit distribution funky.  So many times has fixing each bug in this code only exposed another that I wonder if I mangled something when initially translating your code, which I had struggled to understand because of its structure.  Would you please reorganize your code per my requirements for me to translate it over again without rearrangement?  Here are the most important requirements again:

1. Put constants at the top in ALL CAPS
2. Put everything else into functions
3. Specify what each function should take as arguments, accomplish, and return
4. Make one function called main to call the other functions

If you would, then I could translate your R line-by-line into Python, which I could test for equivalence and only then substantially re-organize for elegance; else, I can only poke my Python code to do what I guess what your R code does and hope that I guessed right.

In other news, I have written a "Hello, world" Python desktop app and frozen it into an executable, which I ran on my machine with one click.  I now know that, in principle, I can create a portable application to run my Python code and display its results.  The next step would be making the executable into one clickable file and adding a Console Commands binding to call it while playing Starsector to avoid breaking workflow.  8)

Quote
Merry Christmas/happy holidays by the way!

Let the festivities begin!  ;D
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 23, 2022, 11:02:42 PM
Will do, though probably not until after 25th! Shouldn't take very long when I have the chance, since it's just a matter of migrating from mathematical writing style and organization to programmer style and organization.

Great news about the executable, this is going to be quite nice I think.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Goumindong on December 24, 2022, 06:49:17 PM
Is spread actually normal? Just looking at how weapons shoot in game seems like it’s a uniform distribution rather than a normal one. (Granted this isn’t a scientific examination but the tails seem awful heavy for a normal distribution)

Now this will eventually approximate a normal distribution but since we’re looking for TTK and doing a sequence by individual shots starting with a normal distribution will be inaccurate if the actual distribution is uniform
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 24, 2022, 09:00:48 PM
Oh, spread is uniform (or more accurately we assume the angle distribution of shots as they leave the gun, which we call spread, is uniform with parameter spread/2). That is the assumption. However, a model based on spread only will underestimate time to kill significantly, due to among other things ship movement and rotation which we are not modeling. That is why we added another parameter, the "fudge factor" ie. a normal distribution on top to model difference from the ideal situation of immobile ships and perfectly aligned gunfire. The distribution we are using is a convolution of these two since the hit location is assumed to be shot angle, from a uniform distribution, + error, from a normal distribution. The normal distribution is calibrated with sim data.

There are some nice graphs of the distributions we are using on page 4 of this thread. https://fractalsoftworks.com/forum/index.php?topic=25536.45 . I also advocate that in the final version it should be possible to turn the normal error off if you want to test the ideal situation - our code permits this.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Goumindong on December 24, 2022, 10:18:47 PM
Ahh that makes sense. I was reading the normal distribution as the spread when catching up when it was error.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 26, 2022, 02:19:05 AM
All right, getting back to this, here you go, this is in my approximation of programmer style going upward in levels of abstraction instead of in order of math. I feel like I don't understand my own code anymore, now that it is presented this way as opposed to the logical order. However, here it is and I even found a small error in the process (a lack of division by 2 for radius). I also included sample runs with randomly generated guns so that you can test that the implementation is correct. Code is here and also as an attachment. (edit: fix some typos and add underscore to a thing)

Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 100, -10, 20, 5)
weapon2 <- c("left phaser", 100, 10, 20, 5)
weapon3 <- c("pd gun 1", 30, -160, 20, 0)
weapon4 <- c("pd gun 2",30, 180, 20, 0)
weapon5 <- c("pd gun 3",30, 160, 20, 0)
weapon6 <- c("photon torpedo", 120, 90, 0, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    return(c(
      weapon[3]-weapon[4]/2+weapon[5]/2,
      weapon[3]+weapon[4]/2-weapon[5]/2
    ))
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}


#1. Transform hit coordinate
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. max( minmean, min( maxmean, angle ) )
#4. Given that angle is the angle of the target relative to our ship, output is the angle that the weapon
#will assume as it tries to target the target

transform_hit_coord <- function(angle, weapon) return(max(weapon$min_mean, min(weapon$max_mean,angle)))

#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship

sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below -359 or above +360
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth/2
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth/2
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }

  #now, for angles -359 to 360 (all possible signed angles, calculate dps)
  angles <- seq(-359,360)
 
  dps_at_angles <- angles
  for (i in 1:720) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #now, note that angle -180 is just angle 180, angle -359 is angle 1, and so on, so
  #these must be summed with angles -179 to 180
  for (i in 1:180) dps_at_angles[i+360] <- dps_at_angles[i+360]+dps_at_angles[i]
  #likewise note that angle 360 is just angle 0, angle 359 is angle -1, and so on
  for (i in 540:720) dps_at_angles[i-360] <- dps_at_angles[i-360]+dps_at_angles[i]
 
  #having summed, select angles -179 to 180
  dps_at_angles <- dps_at_angles[181:540]
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                        [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
 
}

#sample runs
main(ship, 1000, 50, weapons[1:6,])

main(ship, 1000, 50, weapons[7:12,])

main(ship, 1000, 50, weapons[13:18,])

Results for sample runs:
Run 1
(https://i.ibb.co/1nVTKBT/image.png) (https://ibb.co/VNyTDbT)


> main(ship, 1000, 50, weapons[1:6,])
[1] "right phaser:"
 [1] 0.184 0.076 0.087 0.094 0.096 0.095 0.089 0.079 0.066 0.051 0.036 0.023 0.013 0.012
[1] "left phaser:"
 [1] 0.012 0.013 0.023 0.036 0.051 0.066 0.079 0.089 0.095 0.096 0.094 0.087 0.076 0.184
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "photon torpedo:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser    100    -10           20      5    -17.5     -2.5
2    left phaser    100     10           20      5      2.5     17.5
3       pd gun 1     30   -160           20      0   -170.0   -150.0
4       pd gun 2     30    180           20      0    170.0    190.0
5       pd gun 3     30    160           20      0    150.0    170.0
6 photon torpedo    120     90            0      5     90.0     90.0


Run 2
(https://i.ibb.co/xsRYS5M/image.png) (https://ibb.co/N3Hr6YL)


> main(ship, 1000, 50, weapons[7:12,])
[1] "bb gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "space marine teleporter:"
 [1] 0.391 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.391
[1] "turbolaser:"
 [1] 0.165 0.051 0.054 0.056 0.058 0.058 0.058 0.058 0.058 0.058 0.056 0.054 0.051 0.165
[1] "hex bolter:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "singularity projector:"
 [1] 0.444 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.304
[1] "subspace resonance kazoo:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
                      name damage facing tracking_arc spread min_mean max_mean
1                   bb gun      5   -150           11     20   -150.0   -150.0
2  space marine teleporter     78     69          173     29     -3.0    141.0
3               turbolaser     92    122          111      9     71.0    173.0
4               hex bolter     24   -136           38     20   -145.0   -127.0
5    singularity projector     95     28          122     25    -20.5     76.5
6 subspace resonance kazoo     68   -139           12      2   -144.0   -134.0



Run 3
(https://i.ibb.co/GQ9jbqq/image.png) (https://ibb.co/9HhjC77)


> main(ship, 1000, 50, weapons[13:18,])
[1] "left nullspace projector:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "telepathic embarrassment generator:"
 [1] 0.115 0.048 0.055 0.060 0.063 0.064 0.065 0.065 0.065 0.064 0.063 0.059 0.054 0.160
[1] "perfectoid resonance torpedo:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "entropy inverter gun:"
 [1] 0.681 0.022 0.022 0.022 0.022 0.022 0.022 0.022 0.022 0.022 0.021 0.020 0.019 0.062
[1] "mini-collapsar rifle:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "false vacuum tunneler:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
                                name damage facing tracking_arc spread min_mean max_mean
1           left nullspace projector     10     28           54      0      1.0     55.0
2 telepathic embarrassment generator     30    -31           35      8    -44.5    -17.5
3       perfectoid resonance torpedo     34     72           10     17     72.0     72.0
4               entropy inverter gun     78    -60           13     24    -60.0    -60.0
5               mini-collapsar rifle     27     28           16     13     26.5     29.5
6              false vacuum tunneler     32     78          157     20      9.5    146.5



[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 27, 2022, 05:43:07 PM
All right, getting back to this, here you go, this is in my approximation of programmer style going upward in levels of abstraction instead of in order of math.

Wow, this code is much better!  I can see more logical structure now. 

Quote
I feel like I don't understand my own code anymore, now that it is presented this way as opposed to the logical order.

Oh no, I hope I can help!  First a philosophical note: programs are developed, tested, and used extensively but understood only insofar as needed because they are only means to our ends rather than, as mathematical discoveries would be, ends in themselves. The number and scale of these ends entail programs so long and complicated that one person cannot understand them.  Therefore, we should write code with abstraction in mind from the beginning, whether writing from the 'bottom' of core functions to the 'top' of some interface or vice versa, by specifying what we want each method to do, then its arguments and return value, and only then implement the method itself.  Thereafter, we would rather neglect the implementation details to focus on the code in which we will call the method.

Quote
However, here it is and I even found a small error in the process (a lack of division by 2 for radius). I also included sample runs with randomly generated guns so that you can test that the implementation is correct. Code is here and also as an attachment. (edit: fix some typos and add underscore to a thing)

I have a question about the code because it declares weapon variables to which I cannot find any reference.  Also, these variables are declared outside the main method, so I'm worried about where they have ended up.  By the way, a dictionary more clearly stores such unique, unordered, heterogeneous data as the data of a weapon than an array does because each data point can be called by name rather than position.
 
Code
grad_student = {"age" : 29, "money" : 0, "grant" : "pending", "feeling" : "worried"}
if grad_student["age"] > 30: print("This degree is taking a while...")
if grad_student["money"] < 0: print("Outta cash!  I need a loan!")
if grad_student["grant"] == "denied": print("Gotta write another application.")
if instanceof(grad_student["grant"], int):
    print("Hooray!  I got a grant of $" + str(grad_student["grant"]) + "!")
    grad_student["feeling"] = "happy"
if grad_student["feeling"] == "happy":
    print("Did you finish grading those papers?")
    grad_student["feeling"] = "worried"

Also, when declaring elements that will become part of an array, just put them into the array right away rather than declaring and then adding them.

Code
grad_students = ({"age" : 23, "money" : 100, "grant" : "denied", "feeling" : "worried"}, 
                 {"age" : 25, "money" : -200, "grant" : "pending", "feeling" : "worried"},
                 {"age" : 29, "money" : 0, "grant" : "pending", "feeling" : "worried"})
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 27, 2022, 09:44:44 PM
Oh no, I hope I can help!  First a philosophical note: programs are developed, tested, and used extensively but understood only insofar as needed because they are only means to our ends rather than, as mathematical discoveries would be, ends in themselves. The number and scale of these ends entail programs so long and complicated that one person cannot understand them.  Therefore, we should write code with abstraction in mind from the beginning, whether writing from the 'bottom' of core functions to the 'top' of some interface or vice versa, by specifying what we want each method to do, then its arguments and return value, and only then implement the method itself.  Thereafter, we would rather neglect the implementation details to focus on the code in which we will call the method.

Okay, I suppose that makes sense. If the program is documented well enough, and consistent, there is probably no logical obstacle to working on it without understanding the entire program. With my code the assumption might not be perfectly accurate  :D

Quote
I have a question about the code because it declares weapon variables to which I cannot find any reference.  Also, these variables are declared outside the main method, so I'm worried about where they have ended up.  By the way, a dictionary more clearly stores such unique, unordered, heterogeneous data as the data of a weapon than an array does because each data point can be called by name rather than position.

Please specify in more detail, don't know exactly what you mean. I flushed the R environment and re-ran, and it produced the results I posted, so I am certain the program does calculate everything it needs to.

If you are referring to this piece of code:

transform_hit_coord <- function(angle, weapon) return(max(weapon$min_mean, min(weapon$max_mean,angle)))

Then that works because the weapons are actually in a data frame, not a matrix. So the columns do, in fact, have names. I just mostly didn't use those, because these can only be used to call a column of the data frame in R, not a specific element, so it would have been more complicated to refer to columns by name rather than index. This would necessitate writing, for example, damage <- weapons[i,]$damage rather than damage <- weapons[i,2] in the sum_auc function. I assumed during writing that the way this code stores weapons should be just a placeholder anyway, as it should be passed weapons directly from the integrated code and use whatever structure they have there. You should generally replace references to the attributes of weapons with references to whatever is the equivalent element in your code.

By the way, here is one more test, using only those weapons whose indices are prime and this time with no standard deviation for the normal distribution. This revealed an error in my code. Specifically, when doing the math in my head, I had assumed that the width of the uniform distribution is =1 somehow, but of course it is not. Here is the fix.


hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,1/2+z/2/b)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

Note: the change is on line 4:   if(a == 0 & b > 0) return(max(0,min(1,1/2+z/2/b)))


Test result (weapons 2,3,5,7,11,13,17 with sd 0):

(https://i.ibb.co/6HZDrtL/image.png) (https://ibb.co/v3BLVdR)


> main(ship, 1000, 0, weapons[c(2,3,5,7,11,13,17),])
[1] "left phaser:"
 [1] 0.000 0.025 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.030 0.000 0.000
[1] "pd gun 1:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "bb gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "singularity projector:"
 [1] 0.374 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.374
[1] "left nullspace projector:"
 [1] 0 0 0 0 0 0 1 0 0 0 0 0 0 0
[1] "mini-collapsar rifle:"
 [1] 0.000 0.000 0.011 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.585
                      name damage facing tracking_arc spread min_mean max_mean
1              left phaser    100     10           20      5      2.5     17.5
2                 pd gun 1     30   -160           20      0   -170.0   -150.0
3                 pd gun 3     30    160           20      0    150.0    170.0
4                   bb gun      5   -150           11     20   -150.0   -150.0
5    singularity projector     95     28          122     25    -20.5     76.5
6 left nullspace projector     10     28           54      0      1.0     55.0
7     mini-collapsar rifle     27     28           16     13     26.5     29.5


Same, but with "smoothing" from applying a normal distribution with sd 1 px:
(https://i.ibb.co/wrJgRcf/image.png) (https://ibb.co/HpN4Vhj)

> main(ship, 1000, 1, weapons[c(2,3,5,7,11,13,17),])
[1] "left phaser:"
 [1] 0.000 0.025 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.030 0.000 0.000
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "bb gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "singularity projector:"
 [1] 0.374 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.374
[1] "left nullspace projector:"
 [1] 0.0 0.0 0.0 0.0 0.0 0.0 0.5 0.5 0.0 0.0 0.0 0.0 0.0 0.0
[1] "mini-collapsar rifle:"
 [1] 0.000 0.000 0.011 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.585
                      name damage facing tracking_arc spread min_mean max_mean
1              left phaser    100     10           20      5      2.5     17.5
2                 pd gun 1     30   -160           20      0   -170.0   -150.0
3                 pd gun 3     30    160           20      0    150.0    170.0
4                   bb gun      5   -150           11     20   -150.0   -150.0
5    singularity projector     95     28          122     25    -20.5     76.5
6 left nullspace projector     10     28           54      0      1.0     55.0
7     mini-collapsar rifle     27     28           16     13     26.5     29.5


Our normal smoothing of 50 px:
(https://i.ibb.co/1Qftq0P/image.png) (https://ibb.co/SyX4n6M)

> main(ship, 1000, 0, weapons[c(2,3,5,7,11,13,17),])
[1] "left phaser:"
 [1] 0.000 0.025 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.105 0.030 0.000 0.000
[1] "pd gun 1:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "bb gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "singularity projector:"
 [1] 0.374 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.374
[1] "left nullspace projector:"
 [1] 0 0 0 0 0 0 1 0 0 0 0 0 0 0
[1] "mini-collapsar rifle:"
 [1] 0.000 0.000 0.011 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.585
                      name damage facing tracking_arc spread min_mean max_mean
1              left phaser    100     10           20      5      2.5     17.5
2                 pd gun 1     30   -160           20      0   -170.0   -150.0
3                 pd gun 3     30    160           20      0    150.0    170.0
4                   bb gun      5   -150           11     20   -150.0   -150.0
5    singularity projector     95     28          122     25    -20.5     76.5
6 left nullspace projector     10     28           54      0      1.0     55.0
7     mini-collapsar rifle     27     28           16     13     26.5     29.5
> main(ship, 1000, 50, weapons[c(2,3,5,7,11,13,17),])
[1] "left phaser:"
 [1] 0.079 0.048 0.064 0.077 0.088 0.094 0.096 0.094 0.088 0.078 0.064 0.049 0.035 0.046
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "bb gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "singularity projector:"
 [1] 0.374 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.374
[1] "left nullspace projector:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038 0.019 0.014
[1] "mini-collapsar rifle:"
 [1] 0.018 0.013 0.019 0.025 0.030 0.034 0.037 0.039 0.040 0.040 0.040 0.040 0.040 0.585
                      name damage facing tracking_arc spread min_mean max_mean
1              left phaser    100     10           20      5      2.5     17.5
2                 pd gun 1     30   -160           20      0   -170.0   -150.0
3                 pd gun 3     30    160           20      0    150.0    170.0
4                   bb gun      5   -150           11     20   -150.0   -150.0
5    singularity projector     95     28          122     25    -20.5     76.5
6 left nullspace projector     10     28           54      0      1.0     55.0
7     mini-collapsar rifle     27     28           16     13     26.5     29.5
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 27, 2022, 11:54:49 PM
Also, it occurs to me that we can make a mildly diverting graph of how SD affects damage output in a more interesting way than before using this code.

Add a return function to main(), so
  return(dps_at_angles)


and then write

dmatrix <- matrix(0,101*21,3)
for(i in 0:20){
  dmatrix[(i*101+1):(i*101+101),1] <- main(ship, 1000, i*10, matrix(c(1,2,100,100,-20,20,10,10,0,0),2,5))[130:230]
  dmatrix[(i*101+1):(i*101+101),2] <- seq(-50,50)
  dmatrix[(i*101+1):(i*101+101),3] <- i
}
df <- data.frame(dps=dmatrix[,1],angle=dmatrix[,2],sdmult=dmatrix[,3])
library(ggplot2)
ggplot(df,aes(y=dps,x=angle,group=sdmult,color=sdmult*10))+
  geom_line()+
  theme_classic()+
  labs(colour = "SD")+
  scale_color_viridis_c()


To get DPS as a function of angle, with SD increasing in increments of 10 from 0 to 200, for two weapons situated at angles -20 and 20, dealing 100 damage each, with 10 tracking arc each and no spread:
(https://i.ibb.co/hfx33WP/image.png) (https://ibb.co/rsKDD6C)

Note that the value we use normally is 50, so 6th line from the top.

Edit: I had some spare computing time while doing other things so here is a prettier graph:
(https://i.ibb.co/vcmCnjs/image.png) (https://ibb.co/8m2nZ7z)

Same thing with 10 spread and no tracking:

dmatrix <- matrix(0,101*21,3)
for(i in 0:20){
  dmatrix[(i*101+1):(i*101+101),1] <- main(ship, 1000, i*10, matrix(c(1,2,100,100,-20,20,0,0,10,10),2,5))[130:230]
  dmatrix[(i*101+1):(i*101+101),2] <- seq(-50,50)
  dmatrix[(i*101+1):(i*101+101),3] <- i
}
df <- data.frame(dps=dmatrix[,1],angle=dmatrix[,2],sdmult=dmatrix[,3])
library(ggplot2)
ggplot(df,aes(y=dps,x=angle,group=sdmult,color=sdmult*10))+
  geom_line()+
  theme_classic()+
  labs(colour = "SD")+
  scale_color_viridis_c()


(https://i.ibb.co/sVNf1BL/image.png) (https://ibb.co/crZB8mz)
(https://i.ibb.co/ZhLHhtf/image.png) (https://ibb.co/Q6b86zJ)

5 spread and 10 tracking range:

(https://i.ibb.co/YZVsrXR/image.png) (https://ibb.co/740m9QY)

Note that it is theoretically possible for there to be a new maximum in the middle, if it should happen that the normal distribution becomes so dominant and overlapping that the middle is near the peak of both normal distributions for some definition of near. If SD is movement then this would correspond to the enemy ship moving so much that it is better to try to catch it in the middle of the widest possible field of fire rather than aiming guns directly at it. This seems to require more extreme values of the SD for these reasonable guns than 4x of what we are using, though.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 28, 2022, 12:30:56 PM
Okay, I suppose that makes sense. If the program is documented well enough, and consistent, there is probably no logical obstacle to working on it without understanding the entire program. With my code the assumption might not be perfectly accurate  :D

Restructuring a project comprehensible only by reading and understanding every line to be understandable by reading its abstract organization saves the effort of reading the whole project for every bug, change, upgrade, documentation, or discussion.

Quote
Please specify in more detail, don't know exactly what you mean. I flushed the R environment and re-ran, and it produced the results I posted, so I am certain the program does calculate everything it needs to.

I am referring to Section 0
Quote
Code
#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 100, -10, 20, 5)
weapon2 <- c("left phaser", 100, 10, 20, 5)
weapon3 <- c("pd gun 1", 30, -160, 20, 0)
weapon4 <- c("pd gun 2",30, 180, 20, 0)
weapon5 <- c("pd gun 3",30, 160, 20, 0)
weapon6 <- c("photon torpedo", 120, 90, 0, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

I can't find any reference to its variables (weapons1, weapons2, weapons3, ... weapons18) elsewhere in the code but do see weapons mentioned in the calls to main().  I think you meant for weapons to be a test dataset, and if so, I figure you have one there because R is designed presuming the user will make an all-in-one-file project file rather than keep a separate file of test routines and data.  Please designate test data by putting it just before the main function, rather than the top of the file as an CONSTANT_USED_IN_CODE would be, and prefixing the names of these constants with 'test_'.

Code
CONSTANT_USED_IN_CODE = ...


def a_function(data):
    some_variable = CONSTANT_USED_IN_CODE + 1
    ...


def another_function(data):
    some_other_variable = CONSTANT_USED_IN_CODE * 2
    ...


test_data = ...


def main():
    a_function(test_data)
    another_function(test_data)

Edit: Also I have noticed that you have used various tricks to try to save keystrokes and characters in the documentation.  Such tricks are unnecessary because programs can be as long as we like and because each section of documentation should be understandable by itself rather than by reference.

Code
def square(number):
    """
    Return the square of this number.

    number - float

    return - float
    ''''
    ...


def cube(number):
    """
    Return the cube of this number.

    number - float

    return - float
    """
    ...

And so on.  We want documentation we can later scroll to and immediately, explicitly understand.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 28, 2022, 06:45:28 PM
Will do next time! I'll admit I didn't think much about the test data because I assumed that wouldn't be its final form.

What that section of code does is 1. creates vectors called weapon1...weapon18 as manually specified, then

#generate data frame called "weapons"
weapons <- data.frame()
#for i from 1 to the manually specified number of weapons, add a row to the data frame containing the vector whose name is paste("weapon", i) with no separator (e.g. weapon1)
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
#define column names for the data frame
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

That is where "weapons" comes from. Then pass it to main() by giving the rows corresponding to weapons you want to use, e.g. main(ship, range, sd, weapons[1:10, ])

In retrospect this was a poor way of doing things since it results in the columns having type character (string), which we must fix later in main (or should have here). Doing it via a matrix for example would have been better.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 28, 2022, 11:00:07 PM
Will do next time! I'll admit I didn't think much about the test data because I assumed that wouldn't be its final form.

What that section of code does is 1. creates vectors called weapon1...weapon18 as manually specified, then

#generate data frame called "weapons"
weapons <- data.frame()
#for i from 1 to the manually specified number of weapons, add a row to the data frame containing the vector whose name is paste("weapon", i) with no separator (e.g. weapon1)
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
#define column names for the data frame
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

That is where "weapons" comes from. Then pass it to main() by giving the rows corresponding to weapons you want to use, e.g. main(ship, range, sd, weapons[1:10, ])

In retrospect this was a poor way of doing things since it results in the columns having type character (string), which we must fix later in main (or should have here). Doing it via a matrix for example would have been better.

Thanks, now I understand.  A list of dictionaries, each holding every relevant value by name for each weapon, would be clearest.  Please write function documentation with the following format:
Code
#"Return [what the function returns]" or "[Verb] ..." if it has an
#effect rather than a return value, in one sentence.
#
#Paragraph elaborating on this sentence if need be.  Skip
#implementation details unless they cannot be understood
#in the code; e.g., an integral formula.
#
# first_argument - description
# second_argument - description
# third_argument - description
function_name <- function(first_argument, second_argument, third_argument) {
    ...
}
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 29, 2022, 02:17:24 AM
Sure. For now though, see any further obstacles to translation?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 29, 2022, 11:35:49 AM
Here's one:
Code
optimum_angle = x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                        [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]

Could this line be expanded into several?  I can't wrap my head around it.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 29, 2022, 11:45:39 AM
Can do next time I'm at the computer! However, let me explain

optimum_angle = #is
x_axis[ #the element of x axis, where
which( #the element of dps_at_angles is
round(dps_at_angles,3) == round(max(dps_at_angles),3)) #of the sub-vector where dps_at_angles is equal to the maximum value within the elements of the vector dps_at_angles,

                        [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]#the element such that you take the length of the sub-vector above mentioned, divide it by two and take the ceiling
]#end

In English, it reads "the optimum value is the element of the vector x_axis at the index that corresponds to the index that is the index within the vector dps_at_angles of the element of the sub-vector of those elements that are equal to the maximum element in the vector that is at the index of that sub-vector corresponding to the ceiling of the length of the sub-vector divided by two".

I'll write a replacement, I admit it is a little more complex in writing than it was in my head.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 29, 2022, 03:13:01 PM
This code yields the same graph as yours does.
Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
from math import pi


#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


def probability_hit_within (
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
    x - real number
    a - standard deviation of the normal distribution N(0,a),
    b - parameter of the symmetric uniform distribution (-b/2,b/2)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, uniform_distribution_width / 2 + x))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return deg * 2 * radius / 360 * pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / pi


def minimum_mean(weapon: tuple) -> float:
    """
    Return the minimum mean hit probability this weapon over its
    tracking arc.
   
    weapon - a tuple of facing, spread and tracking_range at
             [3],[4],[5]
    """
    return weapon[2] - (weapon[3] + weapon[4]) / 2


def maximum_mean(weapon:tuple) -> float:
    """
    Return the minimum mean hit probability this weapon over its
    tracking arc
   
    weapon - a tuple of facing, spread and tracking_range at
             [3],[4],[5]
    """
    return weapon[2] + (weapon[3] - weapon[4]) / 2


def transform_hit_coord(angle: float, weapon: tuple) -> float:
    """
    Return the facing of this weapon when aiming at this target.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return max(weapon[5], min(weapon[6], angle))


def transformed_angle(angle: float, weapon: tuple) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transform_hit_coord(angle,weapon)


def upper_bounds(ship: tuple, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    ship - a tuple with width at [4] and number of cells stored at [5]
    distance - range to the ship
    """
    ship_angle = ship[4] / (2 * pi * distance)
    cell_angle = ship_angle / ship[5]
    angles = [-ship_angle / 2]
    for i in range(ship[5]): angles[i+1] = angles[i] + cell_angle
    return angles * 2 * pi * distance


def weaponadjustment_px(weapon, optimum_angle, distance):
    """
    #1. Weapon adjustment (pixels)
    #2. A weapon and an angle (should be used for the optimum angle)
    #3. deg_to_arc(transformed_angle)
    #4. Returns the arc from weapon distribution mean to target
    """
    angle_difference = transformed_angle(optimum_angle,weapon)
    return deg_to_arc(angle_difference, distance)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread - a parameter of a symmetric uniform distribution
             (-spread/2, spread/2)
    """
    if standard_deviation == 0 and spread == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread
        return (min(1, max(0, (bounds[0] + spread)) / a),
                + tuple((min(1, max(0, (bounds[j] + spread)) / a)
                         - min(1, max(0, (bounds[j-1] + spread)) / a))
                        for j in range(1, len(bounds)))
                + (1 - min(1, max(0, (bounds[-1] + spread)) / a)),)
    elif spread == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + (1 - cdf(bounds[-1])),)
    return (probability_hit_within(bounds[0], standard_deviation, spread),
            + tuple((probability_hit_within(bounds[j], standard_deviation,
                                            spread)
                     - probability_hit_within(bounds[j-1], standard_deviation,
                                              spread))
                    for j in range(1, len(bounds)))
            + (1 - probability_hit_within(bounds[-1], standard_deviation,
                                          spread)),)


def hit_distribution_at_angle(
        angle: float,
        sd: float,
        upper_bounds: tuple,
        weapon: float,
        distance: float) -> tuple:
    """
    Return the hit distribution at this angle.
   
    The hit distribution is a tuple of probability masses wherein the
    first value is the chance to hit below lowest upper bound, the last
    value is chance to hit above highest upper bound, and the others
    are the probabilities for hits between upper bounds, adjusted for
    ship location.
   
    angle -
    sd - standard error in pixels
    weapon - tuple with spread at index 5
    upper_bounds - tuple of upper bounds
    """
    #convert spread to pixels
    px_spread = deg_to_arc(weapon[4], distance)
    #adjust upper bound vector
    adj_ubs = upper_bounds + weaponadjustment_px(weapon, angle,distance)
    return(hit_distribution(adj_ubs, sd , px_spread))


def hit_probability_at_angle(
        weapon: tuple,
        target_angular_size: float,
        target_positional_angle: float,
        target_positional_angle_error: float) -> float:
    """
    Return the sum of expected damage per second from this weapon
    to a target.
   
    weapons - a tuple of information about a weapon
    target_positional_angle -
    target_angular_size - angle, centered on weapons, from lower to
                          upper target bound
    target_positional_angle_error -
    """
    #angle of the ship's upper bound, in coordinates of the
    #distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below
    #-359 or above +360
    angle = transformed_angle(target_positional_angle, weapon)
    upper_bound = angle + target_angular_size
    lower_bound = angle - target_angular_size
    spread = weapon[4]
    return (probability_hit_within(upper_bound, target_positional_angle_error,
                                   spread)
            - probability_hit_within(lower_bound, target_positional_angle_error,
                                     spread))


def main(weapons: tuple, target: tuple, distance: float, standard_deviation: float):
    """
    Print for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    for weapon in weapons:
        weapon += ([minimum_mean(weapon), maximum_mean(weapon)]
                    if(weapon[4] < weapon[3]) else [weapon[2], weapon[2]])

    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-359,361))
 
    target_angular_size = arc_to_deg(ship[4], distance) / 4
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    dps_at_angles = [
        sum(weapon[1] * hit_probability_at_angle(weapon,
                                                 target_angular_size,
                                                 target_positional_angle,
                                                 target_positional_angle_error)
            for weapon in weapons)
        for target_positional_angle in target_positional_angles]
 
    #now, note that angle -180 is just angle 180, angle -359 is angle
    #1, and so on, so
    #these must be summed with angles -179 to 180
    for i in range(180): dps_at_angles[i+360] += dps_at_angles[i]
    #likewise note that angle 360 is just angle 0, angle 359 is angle
    #-1, and so on
    for i in range(540,720): dps_at_angles[i-360] += dps_at_angles[i]
 
    #having summed, select angles -179 to 180
    dps_at_angles = dps_at_angles[181:540]
   
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179,180)
   
    import matplotlib.pyplot as plt
    plt.scatter(x_axis, dps_at_angles)
    """
    #find the optimum angle by selecting the midmost of those cells that have the highest dps,
    #and from the vector x_axis the angle corresponding to that cell
    #use rounding to avoid errors from numerical math
    optimum_angle = x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                        [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]

    #calculate ship upper bounds
    bounds = upper_bounds(target, distance)

    #calculate and report the distributions for weapons, round for human readability
    for (i in 1:length(weapons[,1])):
        print(paste0(weapons[i,1],":"))
        print(round(hit_distribution_angled(optimum_angle, standard_deviation, bounds, weapons[i,], distance),3))

    #testing section - not to be implemented in final code
    #print a graph of the distribution and our choice of angle
    print(weapons)
    plot(dps_at_angles, x=x_axis)
    abline(v=optimum_angle)
    """

#Section 0. Test ship and weapons.

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
simple_weapons = (["right phaser", 100.0, -10.0, 20.0, 5.0],
                  ["left phaser", 100.0, 10.0, 20.0, 5.0],
                  ["pd gun 1", 30.0, -160.0, 20.0, 0.0],
                  ["pd gun 2", 30.0, 180.0, 20.0, 0.0],
                  ["pd gun 3", 30.0, 160.0, 20.0, 0.0],
                  ["photon torpedo", 120.0, 90.0, 0.0, 5.0])

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
random_weapons = (["bb gun",5,-150,11,20],
                  ["space marine teleporter", 78,69,173,29],
                  ["turbolaser", 92,122,111,9],
                  ["hex bolter", 24,-136,38,20],
                  ["hex bolter", 24,-136,38,20],
                  ["singularity projector", 95,28,122,25],
                  ["subspace resonance kazoo", 68,-139,12,2],
                  ["left nullspace projector", 10,28,54,11],
                  ["telepathic embarrassment generator", 30,-31,35,8],
                  ["perfectoid resonance torpedo", 34,72,10,17],
                  ["entropy inverter gun",78,-60,13,24],
                  ["mini-collapsar rifle", 27,28,16,13],
                  ["false vacuum tunneler", 32,78,157,20])

#We will test against a ship formatted in the normal format
ship = (14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#sample runs
main(simple_weapons, ship, 1000, 50)

main(random_weapons, ship, 1000, 50)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 29, 2022, 10:28:43 PM
Excellent, that means we are almost done here. There is no way that a bunch of random integers will produce the correct graphs unless it is the right code.

There is still the math error that was present in my original code on line 43:
        return max(0, min(1, uniform_distribution_width / 2 + x)) 


This is incorrect if the width of the uniform distribution is not 1, which is an error that I made when originally doing the math in my head. The correct version is, and I think I can write this directly in Python,
Comment:

If the user has chosen to disable the normal distribution, then weapon hit distribution is represented by a rectangle of area 1 and base from -b, b,
and the CDF is equal to the integral from -infinity to x over this box. So, it is 0, when x < -b, and 1, when x > b. When -b <= x <= b, it is
(x-(-b)/2b = 1/2 + x/(2b).


Note that I had also incorrectly stated the parameter of the uniform distribution in the comments. Per our main equation the uniform distribution is U(-b,b) not U(-b/2, b/2). This is a "bug" in the comments only as the code is actually correct (see lines 169-175) but I had forgotten what we are doing in the interval after writing it and didn't bother returning to Willink (who in fact defined it using (-b,b) - see https://www.cambridge.org/core/books/abs/measurement-uncertainty-and-probability/sum-of-normal-and-uniform-variates/DECD19AD9E3D68916CC4B04532418F58 ). So line 38 should read (    b - parameter of the symmetric uniform distribution (-b,b)). It is easy to check that we are doing this correctly by running the graph with SD set to exactly 0 and SD set to a low number, say 0.1 - the graphs must be almost exactly equal if the uniform distribution equation is correct, which is the case with this fix.

So, change line 43 to read
Code
        return max(0, min(1, 1 / 2 + x / uniform_distribution_width / 2))

Then, there is the middle maximum. I'm going to write a separate function for it that does the computations piece by piece.

Code
#Given a vector, return the index of the middle cell among those cells where the vector has its maximum value.
#
#Resolve fractions using a ceiling function. (e.g. in a vector with 7 cells we select cell 3.5 -> cell 4, the middle).
#Note that when porting this to a language with vectors indexed from 0, fractions should be resolved using floor
#(e.g. in a vector with 7 cells we select cell 3.5 -> cell 3, the middle).
#
#In languages with vectors indexed from 1, and with an even number of elements in the vector this will
#result in selecting the lower middle, while in others the upper middle. This has no real effect on the code.
#
#Use rounding to avoid selecting incorrect cells due to floating point arithmetic
#
#vector - a vector containing real numbers
middle_max <- function(vector) {
  vector <- round(vector, 3)
  maxima <- which(vector==max(vector))
  selection <- ceiling((length(maxima))/2)
  return(maxima[selection])
}

Now you can get the optimum angle by

Code
  optimum_angle <- x_axis[middle_max(dps_at_angles)]

I have checked that this produces exactly the same output as the original code.

Attached is a new version with these changes.

Edit: clarified the notes about ceiling and floor and added recommendation to use floor in Py.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 30, 2022, 01:07:08 AM
My translation code produces the same graphs but some strange all-on-one distributions, and I don't know why.
Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
from math import pi


#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))


def probability_hit_between(
        lower_bound: float,
        upper_bound: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit between two coordinates.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    return (probability_hit_within(upper_bound, standard_deviation,
                                   uniform_distribution_width)
            - probability_hit_within(lower_bound, standard_deviation,
                                     uniform_distribution_width))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / pi


def minimum_mean(weapon: tuple) -> float:
    """
    Return the minimum mean hit probability this weapon over its
    tracking arc.
   
    weapon - a tuple of facing, spread and tracking_range at
             [3],[4],[5]
    """
    return weapon[2] - (weapon[3] + weapon[4]) / 2


def maximum_mean(weapon:tuple) -> float:
    """
    Return the minimum mean hit probability this weapon over its
    tracking arc
   
    weapon - a tuple of facing, spread and tracking_range at
             [3],[4],[5]
    """
    return weapon[2] + (weapon[3] - weapon[4]) / 2


def transform_hit_coord(angle: float, weapon: tuple) -> float:
    """
    Return the facing of this weapon when aiming at this target.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return max(weapon[5], min(weapon[6], angle))


def transformed_angle(angle: float, weapon: tuple) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transform_hit_coord(angle,weapon)


def upper_bounds(ship: tuple, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    ship - a tuple with width at [4] and number of cells stored at [5]
    distance - range to the ship
    """
    ship_angle = ship[4] / (2 * pi * distance)
    cell_angle = ship_angle / ship[5]
    angles = [-ship_angle / 2]
    for i in range(ship[5]): angles.append(angles[-1] + cell_angle)
    return tuple(angle * 2 * pi * distance for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread - a parameter of a symmetric uniform distribution
             (-spread, spread)
    """
    if standard_deviation == 0 and spread == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_between(bounds[j-1], bounds[j],
                                            standard_deviation,
                                            spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def hit_probability_at_angle(
        weapon: tuple,
        target_angular_size: float,
        target_positional_angle: float,
        target_positional_angle_error: float) -> float:
    """
    Return the sum of expected damage per second from this weapon
    to a target.
   
    weapons - a tuple of information about a weapon
    target_positional_angle -
    target_angular_size - angle, centered on weapons, from lower to
                          upper target bound
    target_positional_angle_error -
    """
    angle = transformed_angle(target_positional_angle, weapon)
    return probability_hit_between(angle - target_angular_size,
                                   angle + target_angular_size,
                                   target_positional_angle_error,
                                   weapon[4])


def index_of_maximum(row: tuple):
    """
    Return the index of the cell where the vector has its maximum value.
   
    vector - a vector containing real numbers
    """
    maximum = row[0]
    maximum_index = 0
    for i, cell in enumerate(row):
        if cell > maximum:
            maximum = cell
            maximum_index
    return maximum_index


def main(
        weapons: tuple,
        target: tuple,
        distance: float,
        standard_deviation: float):
    """
    Print for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    for weapon in weapons:
        weapon += ([minimum_mean(weapon), maximum_mean(weapon)]
                    if(weapon[4] < weapon[3]) else [weapon[2], weapon[2]])

    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-359,361))
 
    target_angular_size = arc_to_deg(ship[4], distance) / 4
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    dps_at_angles = []
    for target_positional_angle in target_positional_angles:
        damage_per_second = 0
        for weapon in weapons:
            angle = transformed_angle(target_positional_angle, weapon)
            probability = probability_hit_between(
                angle - target_angular_size,
                angle + target_angular_size,
                target_positional_angle_error,
                weapon[4])
            damage_per_second += weapon[1] * probability
        dps_at_angles.append(damage_per_second)
 
    #now, note that angle -180 is just angle 180, angle -359 is angle
    #1, and so on, so
    #these must be summed with angles -179 to 180
    for i in range(180): dps_at_angles[i+360] += dps_at_angles[i]
    #likewise note that angle 360 is just angle 0, angle 359 is angle
    #-1, and so on
    for i in range(540,720): dps_at_angles[i-360] += dps_at_angles[i]
 
    #having summed, select angles -179 to 180
    dps_at_angles = dps_at_angles[181:540]
   
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179,180)
   
    import matplotlib.pyplot as plt
    plt.scatter(x_axis, dps_at_angles)
   
    optimum_angle = x_axis[index_of_maximum(dps_at_angles)]
    bounds = upper_bounds(target, distance)
    for weapon in weapons:
        print(weapon)
        spread_distance = deg_to_arc(weapon[4], distance)
        angle_difference = transformed_angle(optimum_angle,weapon)
        adjustment = deg_to_arc(angle_difference, distance)
        adjusted_bounds = tuple(bound + adjustment for bound in bounds)
        dist = hit_distribution(adjusted_bounds, standard_deviation,
                                spread_distance)
        print(tuple(round(x, 3) for x in dist))
        print()

    #testing section - not to be implemented in final code
    #print a graph of the distribution and our choice of angle
    #plot(dps_at_angles, x=x_axis)
    #abline(v=optimum_angle)

#Test ship and weapons.

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
simple_weapons = (["right phaser", 100.0, -10.0, 20.0, 5.0],
                  ["left phaser", 100.0, 10.0, 20.0, 5.0],
                  ["pd gun 1", 30.0, -160.0, 20.0, 0.0],
                  ["pd gun 2", 30.0, 180.0, 20.0, 0.0],
                  ["pd gun 3", 30.0, 160.0, 20.0, 0.0],
                  ["photon torpedo", 120.0, 90.0, 0.0, 5.0])

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
random_weapons = (["bb gun",5,-150,11,20],
                  ["space marine teleporter", 78,69,173,29],
                  ["turbolaser", 92,122,111,9],
                  ["hex bolter", 24,-136,38,20],
                  ["hex bolter", 24,-136,38,20],
                  ["singularity projector", 95,28,122,25],
                  ["subspace resonance kazoo", 68,-139,12,2],
                  ["left nullspace projector", 10,28,54,11],
                  ["telepathic embarrassment generator", 30,-31,35,8],
                  ["perfectoid resonance torpedo", 34,72,10,17],
                  ["entropy inverter gun",78,-60,13,24],
                  ["mini-collapsar rifle", 27,28,16,13],
                  ["false vacuum tunneler", 32,78,157,20])

#We will test against a ship formatted in the normal format
ship = (14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#sample runs
main(simple_weapons, ship, 1000, 50)

main(random_weapons, ship, 1000, 50)
[close]
Result
['right phaser', 100.0, -10.0, 20.0, 5.0, -22.5, -2.5]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['left phaser', 100.0, 10.0, 20.0, 5.0, -2.5, 17.5]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['pd gun 1', 30.0, -160.0, 20.0, 0.0, -170.0, -150.0]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.001, 0.002, 0.005, 0.013, 0.026, 0.048, 0.078, 0.827)

['pd gun 2', 30.0, 180.0, 20.0, 0.0, 170.0, 190.0]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['pd gun 3', 30.0, 160.0, 20.0, 0.0, 150.0, 170.0]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['photon torpedo', 120.0, 90.0, 0.0, 5.0, 90.0, 90.0]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['bb gun', 5, -150, 11, 20, -150, -150]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.001, 0.002, 0.003, 0.993)

['space marine teleporter', 78, 69, 173, 29, -32.0, 141.0]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['turbolaser', 92, 122, 111, 9, 62.0, 173.0]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['hex bolter', 24, -136, 38, 20, -165.0, -127.0]
(0.025, 0.014, 0.018, 0.021, 0.023, 0.025, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.692)

['hex bolter', 24, -136, 38, 20, -165.0, -127.0]
(0.025, 0.014, 0.018, 0.021, 0.023, 0.025, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.692)

['singularity projector', 95, 28, 122, 25, -45.5, 76.5]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['subspace resonance kazoo', 68, -139, 12, 2, -146.0, -134.0]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['left nullspace projector', 10, 28, 54, 11, -4.5, 49.5]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['telepathic embarrassment generator', 30, -31, 35, 8, -52.5, -17.5]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['perfectoid resonance torpedo', 34, 72, 10, 17, 72, 72]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['entropy inverter gun', 78, -60, 13, 24, -60, -60]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['mini-collapsar rifle', 27, 28, 16, 13, 13.5, 29.5]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)

['false vacuum tunneler', 32, 78, 157, 20, -10.5, 146.5]
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 30, 2022, 02:07:32 AM
Well, does it also print/graph the correct optimum angle, if you ask it to? These are 0 for the "simple weapons" run and 73 for the "random weapons" run (pictured, weapons 7 to 18; note that your code actually has two copies of the "hex bolter"). If so, then there are only two places where the error can be: either in the adjusted cell upper bounds, or the hit distribution function.

Here is the whole "random weapons" run:
(https://i.ibb.co/PNh3d6b/image.png) (https://ibb.co/Tk8psgn)
 main(ship, 1000, 50, weapons[7:18,])
[1] "bb gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "space marine teleporter:"
 [1] 0.391 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.391
[1] "turbolaser:"
 [1] 0.165 0.051 0.054 0.056 0.058 0.058 0.058 0.058 0.058 0.058 0.056 0.054 0.051 0.165
[1] "hex bolter:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "singularity projector:"
 [1] 0.374 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.374
[1] "subspace resonance kazoo:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left nullspace projector:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "telepathic embarrassment generator:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "perfectoid resonance torpedo:"
 [1] 0.344 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.285
[1] "entropy inverter gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "mini-collapsar rifle:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "false vacuum tunneler:"
 [1] 0.342 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.342
                                 name damage facing tracking_arc spread min_mean max_mean
1                              bb gun      5   -150           11     20   -150.0   -150.0
2             space marine teleporter     78     69          173     29     -3.0    141.0
3                          turbolaser     92    122          111      9     71.0    173.0
4                          hex bolter     24   -136           38     20   -145.0   -127.0
5               singularity projector     95     28          122     25    -20.5     76.5
6            subspace resonance kazoo     68   -139           12      2   -144.0   -134.0
7            left nullspace projector     10     28           54      0      1.0     55.0
8  telepathic embarrassment generator     30    -31           35      8    -44.5    -17.5
9        perfectoid resonance torpedo     34     72           10     17     72.0     72.0
10               entropy inverter gun     78    -60           13     24    -60.0    -60.0
11               mini-collapsar rifle     27     28           16     13     26.5     29.5
12              false vacuum tunneler     32     78          157     20      9.5    146.5
[1] 73

The last line is where I asked it to print the optimum angle.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on December 31, 2022, 10:36:09 AM
It didn't, but I have fixed some bugs, and now it does.  I had flipped a sign in the minimum means code and implemented a simple index of maximum search rather than your middle index of approximate maxima search.
Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
from math import pi


#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))


def probability_hit_between(
        lower_bound: float,
        upper_bound: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit between two coordinates.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    return (probability_hit_within(upper_bound, standard_deviation,
                                   uniform_distribution_width)
            - probability_hit_within(lower_bound, standard_deviation,
                                     uniform_distribution_width))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / pi


def minimum_mean(weapon: tuple) -> float:
    """
    Return the minimum mean hit probability this weapon over its
    tracking arc.
   
    weapon - a tuple of facing, spread and tracking_range at
             [3],[4],[5]
    """
    return weapon[2] - (weapon[3] - weapon[4]) / 2


def maximum_mean(weapon: tuple) -> float:
    """
    Return the minimum mean hit probability this weapon over its
    tracking arc
   
    weapon - a tuple of facing, spread and tracking_range at
             [3],[4],[5]
    """
    return weapon[2] + (weapon[3] - weapon[4]) / 2


def transform_hit_coord(angle: float, weapon: tuple) -> float:
    """
    Return the facing of this weapon when aiming at this target.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return max(weapon[5], min(weapon[6], angle))


def transformed_angle(angle: float, weapon: tuple) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transform_hit_coord(angle,weapon)


def upper_bounds(ship: tuple, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    ship - a tuple with width at [4] and number of cells stored at [5]
    distance - range to the ship
    """
    ship_angle = ship[4] / (2 * pi * distance)
    cell_angle = ship_angle / ship[5]
    angles = [-ship_angle / 2]
    for i in range(ship[5]): angles.append(angles[-1] + cell_angle)
    return tuple(angle * 2 * pi * distance for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread - a parameter of a symmetric uniform distribution
             (-spread, spread)
    """
    if standard_deviation == 0 and spread == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_between(bounds[j-1], bounds[j],
                                            standard_deviation,
                                            spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def hit_probability_at_angle(
        weapon: tuple,
        target_angular_size: float,
        target_positional_angle: float,
        target_positional_angle_error: float) -> float:
    """
    Return the sum of expected damage per second from this weapon
    to a target.
   
    weapons - a tuple of information about a weapon
    target_positional_angle -
    target_angular_size - angle, centered on weapons, from lower to
                          upper target bound
    target_positional_angle_error -
    """
    angle = transformed_angle(target_positional_angle, weapon)
    return probability_hit_between(angle - target_angular_size,
                                   angle + target_angular_size,
                                   target_positional_angle_error,
                                   weapon[4])


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 3) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]


def main(
        weapons: tuple,
        target: tuple,
        distance: float,
        standard_deviation: float):
    """
    Print for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    for weapon in weapons:
        weapon += ([minimum_mean(weapon), maximum_mean(weapon)]
                    if(weapon[4] < weapon[3]) else [weapon[2], weapon[2]])

    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-359,361))
 
    target_angular_size = arc_to_deg(ship[4], distance) / 4
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    dps_at_angles = []
    for target_positional_angle in target_positional_angles:
        damage_per_second = 0
        for weapon in weapons:
            angle = transformed_angle(target_positional_angle, weapon)
            probability = probability_hit_between(
                angle - target_angular_size,
                angle + target_angular_size,
                target_positional_angle_error,
                weapon[4])
            damage_per_second += weapon[1] * probability
        dps_at_angles.append(damage_per_second)
 
    #now, note that angle -180 is just angle 180, angle -359 is angle
    #1, and so on, so
    #these must be summed with angles -179 to 180
    for i in range(180): dps_at_angles[i+360] += dps_at_angles[i]
    #likewise note that angle 360 is just angle 0, angle 359 is angle
    #-1, and so on
    for i in range(540,720): dps_at_angles[i-360] += dps_at_angles[i]
 
    #having summed, select angles -179 to 180
    dps_at_angles = dps_at_angles[181:540]
   
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179,180)
   
    import matplotlib.pyplot as plt
    plt.scatter(x_axis, dps_at_angles)
   
    optimum_angle_index = middle_index_of_approximate_maxima(dps_at_angles)
    optimum_angle = x_axis[optimum_angle_index]
    bounds = upper_bounds(target, distance)
    for weapon in weapons:
        print(weapon)
        spread_distance = deg_to_arc(weapon[4], distance)
        angle_difference = transformed_angle(optimum_angle,weapon)
        adjustment = deg_to_arc(angle_difference, distance)
        adjusted_bounds = tuple(bound + adjustment for bound in bounds)
        dist = hit_distribution(adjusted_bounds, standard_deviation,
                                spread_distance)
        print(tuple(round(x, 3) for x in dist))
        print()
    print("optimum angle:", optimum_angle)

    #testing section - not to be implemented in final code
    #print a graph of the distribution and our choice of angle
    #plot(dps_at_angles, x=x_axis)
    #abline(v=optimum_angle)

#Test ship and weapons.

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
simple_weapons = (["right phaser", 100.0, -10.0, 20.0, 5.0],
                  ["left phaser", 100.0, 10.0, 20.0, 5.0],
                  ["pd gun 1", 30.0, -160.0, 20.0, 0.0],
                  ["pd gun 2", 30.0, 180.0, 20.0, 0.0],
                  ["pd gun 3", 30.0, 160.0, 20.0, 0.0],
                  ["photon torpedo", 120.0, 90.0, 0.0, 5.0])

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
random_weapons = (["bb gun",5,-150,11,20],
                  ["space marine teleporter", 78,69,173,29],
                  ["turbolaser", 92,122,111,9],
                  ["hex bolter", 24,-136,38,20],
                  ["singularity projector", 95,28,122,25],
                  ["subspace resonance kazoo", 68,-139,12,2],
                  ["left nullspace projector", 10,28,54,0],
                  ["telepathic embarrassment generator", 30,-31,35,8],
                  ["perfectoid resonance torpedo", 34,72,10,17],
                  ["entropy inverter gun",78,-60,13,24],
                  ["mini-collapsar rifle", 27,28,16,13],
                  ["false vacuum tunneler", 32,78,157,20])

#We will test against a ship formatted in the normal format
ship = (14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#sample runs
#main(simple_weapons, ship, 1000, 50)

main(random_weapons, ship, 1000, 50)
[close]
Result
['bb gun', 5, -150, 11, 20, -150, -150]
(1.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0)

['space marine teleporter', 78, 69, 173, 29, -3.0, 141.0]
(0.391, 0.018, 0.018, 0.018, 0.018, 0.018, 0.018, 0.018, 0.018, 0.018, 0.018, 0.018, 0.018, 0.391)

['turbolaser', 92, 122, 111, 9, 71.0, 173.0]
(0.165, 0.051, 0.054, 0.056, 0.058, 0.058, 0.058, 0.058, 0.058, 0.058, 0.056, 0.054, 0.051, 0.165)

['hex bolter', 24, -136, 38, 20, -145.0, -127.0]
(1.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0)

['singularity projector', 95, 28, 122, 25, -20.5, 76.5]
(0.374, 0.021, 0.021, 0.021, 0.021, 0.021, 0.021, 0.021, 0.021, 0.021, 0.021, 0.021, 0.021, 0.374)

['subspace resonance kazoo', 68, -139, 12, 2, -144.0, -134.0]
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)

['left nullspace projector', 10, 28, 54, 0, 1.0, 55.0]
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)

['telepathic embarrassment generator', 30, -31, 35, 8, -44.5, -17.5]
(1.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0)

['perfectoid resonance torpedo', 34, 72, 10, 17, 72, 72]
(0.344, 0.031, 0.031, 0.031, 0.031, 0.031, 0.031, 0.031, 0.031, 0.031, 0.031, 0.031, 0.031, 0.285)

['entropy inverter gun', 78, -60, 13, 24, -60, -60]
(1.0, 0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0)

['mini-collapsar rifle', 27, 28, 16, 13, 26.5, 29.5]
(1.0, 0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, -0.0, 0.0, -0.0)

['false vacuum tunneler', 32, 78, 157, 20, 9.5, 146.5]
(0.342, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.026, 0.342)

optimum angle: 73
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on December 31, 2022, 12:19:17 PM
All right, great job! Seems like we now have the tools to import and analyze ships, at least to a degree of precision. By the way, and you probably know this but just to be sure, the code is in no way fixed to right = 0 degrees. It can be anything so long as all the ships and guns use the same reference point, for example if 0 is straight ahead in game files (I have no idea) then that is fine so long as it is the same for all ships.

Want to wander into projective geometry to implement more complex shapes or proceed with this?

Happy new year!

P.s. attached is how I imagine you might go about it. (If it should turn out to be difficult to formulate rules for which index to use from which direction using just 1 cell index, the alternative computation is just calculate cell distance from gun and hit nearest when the boundaries of multiple cells overlap. It might be that we have to do that because the rules for cell selection could be unclear for guns at the middle. (Edit: removed idea about using modular arithmetic and integers for this as euclidean distance might be more practical later if we want to e.g. add range)). The indices are a little wrong in the picture but the idea is that you would number cells from back to front and from right to left if the gun is to the left, and left to right if gun is to the right. Then you would find the corners of all cells projected to the circle. Then you find all overlaps. Then for all overlaps you select the highest cell number as the cell which will be hit. Then you calculate the probability distribution over the resulting line of cell bounds and map the hits to the ship armor grid. And the mathematical question is does this procedure always result in hitting the nearest cell in terms of euclidean distance among those whose projections to the circle overlap.  (I should think so when the ship is to the side, because let's say the enemy ship is rectangular. Then the vector from our ship to cell 1 is (x, y). Then let's say that the number of a particular cell is ar+c, where a is the number of rows, r is the number of cells per row, and c is the column index ie. the remainder of cell number / r. Then, the vector from cell 1 to that cell is (-sa, -sc) if s is the side of an armor cell. Without loss of generality we can say s=1. Then the euclidean distance to the cell from our ship is sqrt((x-a)^2+(y-c)^2). Now let's say that a cell has a shorter euclidean distance but a lower index. Then it must be the case that either a or c is lower (or both). But then it follows that the euclidean distance is greater. Then the question is does this apply to ships of an arbitrary shape. Most likely so, because we might conceptualize of the arbitrary shape as a rectangle but with cells that can't be hit, and if a cell can't be hit then the task is to find the highest index in the rectangle behind it. However, how to define the index when the enemy ship is not to one side but in the middle of our field of fire?)

But, as stated, I don't really think this is necessary to publish for now and it might lead to some work, so your call. It does seem like it will be pretty computationally intensive compared to what we have now.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 01, 2023, 08:04:37 AM
All right, great job! Seems like we now have the tools to import and analyze ships, at least to a degree of precision.

Woooooooo!   Yaaaaay! :D Hahahaha.  Thank you so much.  Here's what we have more specifically:

- a data pipeline to import all the data from vanilla (and some mods) without having to launch Starsector
- the analytics to evaluate how combinations of closely-spaced weapons perform against unshielded slab-sided armor at long range
- a (hopefully-portable) app we could modify to let you press a button to run the pipeline and analytics

Quote
By the way, and you probably know this but just to be sure, the code is in no way fixed to right = 0 degrees. It can be anything so long as all the ships and guns use the same reference point, for example if 0 is straight ahead in game files (I have no idea) then that is fine so long as it is the same for all ships.

Oh, well that's fantastic news.

Quote
Want to wander into projective geometry to implement more complex shapes or proceed with this?

I wonder if the following pre-calculations might also help:

1. Gather the collision bounds of the ship.
2. Create an armor grid shaped for the ship.
3. Map each outer armor grid cells to its adjacent bound.
4. For each outer armor grid cell,
    a. Draw a line from its four corners to each weapon
    b. If any of these lines does not intersect a non-adjacent bound
        i. Do arbitrary math here to assign how much fire this armor cell would take

That arbitrary math could have plenty of quick and convenient approximations, too.  Speaking of performance, I should note that scientific computing time is freely available from many online cloud providers, so we can use more than a potato if sorely pressed.

Quote
Happy new year!

Happppppyyyyy newwww yeaaaaaaar! :D

Quote
P.s. attached is how I imagine you might go about it. (If it should turn out to be difficult to formulate rules for which index to use from which direction using just 1 cell index, the alternative computation is just calculate cell distance from gun and hit nearest when the boundaries of multiple cells overlap. It might be that we have to do that because the rules for cell selection could be unclear for guns at the middle. (Edit: removed idea about using modular arithmetic and integers for this as euclidean distance might be more practical later if we want to e.g. add range)). The indices are a little wrong in the picture but the idea is that you would number cells from back to front and from right to left if the gun is to the left, and left to right if gun is to the right. Then you would find the corners of all cells projected to the circle. Then you find all overlaps. Then for all overlaps you select the highest cell number as the cell which will be hit. Then you calculate the probability distribution over the resulting line of cell bounds and map the hits to the ship armor grid. And the mathematical question is does this procedure always result in hitting the nearest cell in terms of euclidean distance among those whose projections to the circle overlap.  (I should think so when the ship is to the side, because let's say the enemy ship is rectangular. Then the vector from our ship to cell 1 is (x, y). Then let's say that the number of a particular cell is ar+c, where a is the number of rows, r is the number of cells per row, and c is the column index ie. the remainder of cell number / r. Then, the vector from cell 1 to that cell is (-sa, -sc) if s is the side of an armor cell. Without loss of generality we can say s=1. Then the euclidean distance to the cell from our ship is sqrt((x-a)^2+(y-c)^2). Now let's say that a cell has a shorter euclidean distance but a lower index. Then it must be the case that either a or c is lower (or both). But then it follows that the euclidean distance is greater. Then the question is does this apply to ships of an arbitrary shape. Most likely so, because we might conceptualize of the arbitrary shape as a rectangle but with cells that can't be hit, and if a cell can't be hit then the task is to find the highest index in the rectangle behind it. However, how to define the index when the enemy ship is not to one side but in the middle of our field of fire?)

But, as stated, I don't really think this is necessary to publish for now and it might lead to some work, so your call. It does seem like it will be pretty computationally intensive compared to what we have now.

See above idea to perhaps make it easier.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 01, 2023, 08:23:42 AM
Right. Adding shields to the model should be almost trivial. Just keep track of time and soft and hard flux and use the shield on/off algorithm above described.

The idea you described sounds quite reasonable. However there is a big obstacle to implementing all of these: I have no idea how the ship's armor grid relates to its geometric shape in general terms. Vanshilar gave the horizontal size of armor cells but what about the vertical? And is there padding and how does it works? Anybody know about this?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 01, 2023, 12:44:01 PM
Right. Adding shields to the model should be almost trivial. Just keep track of time and soft and hard flux and use the shield on/off algorithm above described.

Which algorithm would that be?  I can't find it easily by typing "shield" into the search-bar of this thread.

Quote
The idea you described sounds quite reasonable. However there is a big obstacle to implementing all of these: I have no idea how the ship's armor grid relates to its geometric shape in general terms. Vanshilar gave the horizontal size of armor cells but what about the vertical? And is there padding and how does it works? Anybody know about this?

I have a guess.  Rather than thinking of the armor grid having a 'real' part 'inside' the ship with 'padding' sticking out, think of the ship bounds as being centered in and drawn on a grid of square cells, each of which has an associated armor value, which begins equal to the ship's armor rating / 15.  When a shot hits the bounds of the ship, find which cell the shot is in and pool, calculate, and distribute according to the matrix and grid laid out earlier.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 01, 2023, 08:30:20 PM
Seems reasonable. However, this method also leads to a hit to the ship being able to damage cells across a gap in the ship (e.g. imagine a ship that consists of two parallel lines of armor cells 1 cell width apart, then with this algorithm hits on either line will also damage the other). Is that how it works?

Also, another case, does that mean that for a ship shaped like a triangle, the armor grid is shaped like a stepped pyramid, with hittable armor cells everywhere where at least 1 pixel of the triangle exceeds a grid boundary? Alternative version: say that somebody has specified a ship that is exactly 1 armor cell wide and 1 armor cell + 2 pixels long. Does this ship have 1, 2 or 3 hittable armor cells? For example if the logic is center the ship on a grid of square armor cells, then add hittable armor cells to all squares where the ship has a nonzero area, then this would have 3 armor cells with shots parallel to ship length hitting different cells depending on direction and shots parallel to ship width hitting mostly one cell. And another: say that someone has made a ship that is square and the size of 1 armor cell, but rotated it 45 degrees so it is oriented like a diamond. Does this ship have 5 different hittable armor cells?

The algorithm for shields is
1. At the start of each second, compute incoming damage
2. Then if you can block without overloading, do so, and deal all damage that second to shields, otherwise to armor
3. If you did not block, dissipate first soft and then hard flux equal to flux dissip stat. If you did, soft flux only and equal to flux dissip - shield upkeep.

There is still the question of what to do with ships with different dps optimum angles vs shield and armor. One step at a time I guess. There are broadly four different strategies: 1. ignore, 2. assume ship always uses optimum (can rotate instantly), 3. compute some kind of an index beforehand to determine which to use for the whole combat (optimal rotation when unable to rotate), 4. rotate the ship based on some kind of a threshold with the rotation taking actual time (pseudo-ai).

It occurs to me that there is a terrifying possibility that is permitted by options 3 and 4: what if the optimum angles are angles that are neither the highest dps vs enemy shields nor the highest vs armor?. For example imagine a ship that rotates extremely slowly and has a fixed gun that deals 399 energy dps straight ahead, and two other fixed guns, one dealing 200 kinetic dps to the exact left and one dealing 200 he dps to the exact right. It seems like the correct choice for this ship would be to not rotate at all using options 3-4, but we would not find this choice using the dps at angles function we have now, just checking dps vs shields and dps vs armor...

I suppose we would find this maximum by computing an "average real dps vs shields and armor" which would be something like shield dps*shield efficacy - shield regen vs shields and the solution f'(1) to f'(t)=dps*hit strength/(hit strength+armor -f(t)) vs armor and then maximize the average of those two, possibly weighed with something.

At this point it might be helpful to know how the game's AI makes that decision. Because it probably doesn't involve solving this but rather something like "point biggest gun at target". If we knew that we could just replicate the process since that is the most valid way to do it even if it doesn't lead to mathematically optimal damage.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 02, 2023, 06:47:03 AM
Seems reasonable. However, this method also leads to a hit to the ship being able to damage cells across a gap in the ship (e.g. imagine a ship that consists of two parallel lines of armor cells 1 cell width apart, then with this algorithm hits on either line will also damage the other). Is that how it works?

Come to think of it, our previous approach would damage the cells on both sides too.  We would need to check whether each cell of the 'splash' of armor damage 'crossed' a bound before applying it to cells inside the bounds of the ship; oof, that step would multiply the work at worst 25 x the number of ship bounds, which are at least 4 and possibly as high as 40, for 100 to 1,000 extra operations per shot per ship.  Maybe we could check whether this check is necessary once per ship and then consider it only when necessary.  Even then, with worst case 100 outer armor cells, that check in and of itself would be 10,000 to 100,000 steps per ship at startup.  We might have to use caching.   :-\

Quote
Also, another case, does that mean that for a ship shaped like a triangle, the armor grid is shaped like a stepped pyramid, with hittable armor cells everywhere where at least 1 pixel of the triangle exceeds a grid boundary? Alternative version: say that somebody has specified a ship that is exactly 1 armor cell wide and 1 armor cell + 2 pixels long. Does this ship have 1, 2 or 3 hittable armor cells? For example if the logic is center the ship on a grid of square armor cells, then add hittable armor cells to all squares where the ship has a nonzero area, then this would have 3 armor cells with shots parallel to ship length hitting different cells depending on direction and shots parallel to ship width hitting mostly one cell. And another: say that someone has made a ship that is square and the size of 1 armor cell, but rotated it 45 degrees so it is oriented like a diamond. Does this ship have 5 different hittable armor cells?

It's not about exceeding a grid boundary, though.  If any corner of an outer armor cell is within range of a weapon, and if a line from that corner to that weapon does not cross a bound not adjacent to that cell, then that cell is at least slightly exposed to that weapon, and we can do arbitrary math.

Quote
The algorithm for shields is
1. At the start of each second, compute incoming damage
2. Then if you can block without overloading, do so, and deal all damage that second to shields, otherwise to armor
3. If you did not block, dissipate first soft and then hard flux equal to flux dissip stat. If you did, soft flux only and equal to flux dissip - shield upkeep.

Aha, thank you!

Quote
There is still the question of what to do with ships with different dps optimum angles vs shield and armor. One step at a time I guess. There are broadly four different strategies: 1. ignore, 2. assume ship always uses optimum (can rotate instantly), 3. compute some kind of an index beforehand to determine which to use for the whole combat (optimal rotation when unable to rotate), 4. rotate the ship based on some kind of a threshold with the rotation taking actual time (pseudo-ai).

Here we get into questions of aggression versus defense as well: should you face the enemy with your toughest armor or best weapons?  Probably just rotate toward optimum each step.

Quote
It occurs to me that there is a terrifying possibility that is permitted by options 3 and 4: what if the optimum angles are angles that are neither the highest dps vs enemy shields nor the highest vs armor?. For example imagine a ship that rotates extremely slowly and has a fixed gun that deals 399 energy dps straight ahead, and two other fixed guns, one dealing 200 kinetic dps to the exact left and one dealing 200 he dps to the exact right. It seems like the correct choice for this ship would be to not rotate at all using options 3-4, but we would not find this choice using the dps at angles function we have now, just checking dps vs shields and dps vs armor...

I suppose we would find this maximum by computing an "average real dps vs shields and armor" which would be something like shield dps*shield efficacy - shield regen vs shields and the solution f'(1) to f'(t)=dps*hit strength/(hit strength+armor -f(t)) vs armor and then maximize the average of those two, possibly weighed with something.

At this point it might be helpful to know how the game's AI makes that decision. Because it probably doesn't involve solving this but rather something like "point biggest gun at target". If we knew that we could just replicate the process since that is the most valid way to do it even if it doesn't lead to mathematically optimal damage.

We might have to ask Alex.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 02, 2023, 06:57:30 AM
Yeah we are going to have to ask Alex or somebody else who knows, because look at this sucker:
(https://i.ibb.co/P9wQTxp/image.png) (https://ibb.co/jWgRVhK)
(https://i.ibb.co/WndgCMt/image.png) (https://ibb.co/G71cm8T)

It COULD go for 4*500 + 1*200 + 4*150 = 2800 DPS vs shields from 4 Heavy Needlers, 1 Graviton Beam and 4 IR Pulse Lasers.

Instead it prefers to blast the shields with 2x 60 + 2 x 125 DPS vs shields from 2 Hellbores and 2 Heavy Maulers. I tried this test 5 times vs the Dominator and 2 times vs the Aurora and the behavior is identical. Were it to go for the mathematical optimum it would make some somewhat different choices.

Of course it could be the range, which causes it to assume an incorrect position initially and then persist, since I didn't follow through to see what happens as the fight goes on, and whether it would correct itself, but it shows that it's not sufficient to just assume the AI will home straight to the optimum.

Anyway, settling this might take a while, so want to wrap up a pre-release where player sets ship angle manually for ship tests and that is suitable for weapons testing?(this should be much less confusing if it can print out a dps at angles graph) Might also get others interested.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 02, 2023, 02:25:33 PM
Yes, let's ask Alex and wrap a pre-release for now.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 02, 2023, 11:25:37 PM
All right, I sent a PM to Alex and we'll see if he responds. I can understand if he doesn't feel like revealing this stuff, though.

Here's some further thoughts on the hitting cells thing. Won't be programming it in the near future as there is something slightly more pressing right now so just tossing this out there. I am not particularly great at geometry (or more like don't know anything about it) so there are probably much smarter ways of doing this.

[attachment deleted by admin]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 03, 2023, 05:20:49 AM
Coordinate pairs of real numbers would be simpler to understand and implement than complex numbers, and I suspect the armor grid could even more simply be rectangular, with each cell containing a boolean indicating its being an outer shell within the ship bounds.  Then we could vectorize the calculation to:

1. Falsify cells outside the bounds
2. Falsify all still-true cells surrounded by true cells
3. Determine which true cells are affected.

Here's a catch to any approach: the normal distribution we've added to the uniform spread distribution approaches but never quite reaches zero.  Therefore, any cell on the entire weapon-side of the ship might be hit.  How do you want to handle this issue?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 03, 2023, 05:36:49 AM
It is not in fact true that all cells could be hit with the normal distribution.

The problem should be handled like this, to use the example of one rectangle:

- project the rectangle to the circle defined by the gun and range
- apply the probability distribution to coordinates (be it angles or pixels) on the circle
- when there is an overlap in the projection you hit the closest point using whatever metric you desire, be it a system of indexing or euclidean distance

It is never possible to hit the other side of the ship, because you only consider the closest point of the enemy ship. The rest are behind the closest points so unhittable even if there is an error. (Unless we go out of our way to model this possibility by adding both an x and an y directional error)

To explain a little further, our probability distributions only work in one dimension. So when we are collapsing a two dimensional object to one dimension we will have overlaps. We must have one rule to deal with these overlaps. The natural rule is hit the closest because anything else is not what happens in the game. As a result you only hit those points visible from the gun unless we add another dimension to the distribution.

Wait, my bad. You mean any cell on the side of the enemy ship near our ship could be hit. Yes, this is true, but just statistics. The normal distribution really means error from the perfect situation of alignment and no movement. In extremely rare cases we might be extremely off. But this should make it more realistic, not less because we are not in the perfect situation in the game, hence the normal dist.

In a sense it would be more realistic to be able to hit the back of the enemy ship with very small probability, because the enemy ship is also not always rotated towards us. Don't particularly feel like implementing that though, but I guess if you were using the euclidean distance to cell as the metric to see which cell you hit, then you would add an error to that distance, maybe? Of course the real solution with no hacking would be to have the boundaries of the enemy ship include a random rotational error. Or even more appropriately go back to the Brownian motion idea but with rotation.

By the way, re-examining the fundamentals really makes me wonder: given that we have a probability wave of damage acting on an armor, is there really no simple solution, say, something like a propagating wave, that describes the armor state? The equation for a single cell is f'(t)=max(0.15d, hd/(h+max(0.05 starting armor/15, a-f(t))). For an entire armor matrix it can be expressed as a matrix differential equation using the matrix notation presented above. But I'm out of my depth here for the time being again and also need to work on figures for some paper again. Still, food for thought.

I mean it seems like there should be a single equation when we have a fixed effect acting repeatedly on a bunch of cells characterized by one value with fixed rules of how they interact with one another.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 03, 2023, 01:05:19 PM
I'm confused because I understand applying the normal distribution to mean that the ship is is at one angle but might be at another: what angle would the armor cells be at over those angles at which the ship only might be?  I suppose we could find a formula for what cells would be exposed around a ring of ship angles for a weapon of uniform spread, but then might we not as well use those results directly?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 03, 2023, 08:36:45 PM
Well, if we are using the normal distribution as is and using the coordinates projected to the circle arc, then we are essentially saying the enemy ship's position has a random normal error but it is along the same arc and the same for all cells. But in reality this is an assumption of convenience: if the enemy whip's angle is uncertain then other cells might be exposed. And if the enemy ship is further away then not only does that affect range but also the armor cells' projections would be smaller. We"fix" this by adjusting the SD parameter until it matches the results from simming. But what should happen in reality is in fact the error is applied to enemy ship's position and rotation before the projection as you say, you are correct.

Where that leads us though were we to go that route is calculating a uniform distribution over some bins whose parameters (and also which bins are available) are normally distributed. I am not sure how to do that.

This could be done numerically though, but the problem is there is theoretically an infinite number of possible ships. Or if we restrict ourselves to a 20x20 armor grid, then 2^400-1 ships. So pre-computing is not an option. (If we restrict ourselves to ships that are continuous, then it actually becomes the following math problem: given a n x n grid, using one color, in how many different ways can you color any number of cells so that all colored cells are connected?)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 03, 2023, 09:30:00 PM
Uh-oh.  Maybe we should change our approach to avoid these problems.  I have one idea: modifying the normal distribution, perhaps by constraint to +- 90 degrees or +- spread arc from the weapon direction,  or ignoring it entirely might reduce geometric complexity. 
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 03, 2023, 09:51:11 PM
I'm not sure how this idea could be done without a normal distribution, because isn't that where the enemy ship's random angle is assumed to come from?

The simplest way to avoid all of these issues is to model the enemy ship as a line as we have, and note in documentation that the enemy ship is modeled as a slab of armor with defensive statistics equal to enemy ship and rotation of the enemy ship is not considered. This is unsatisfying on a level, but on the other hand we are also not currently pursuing error correction to the armor, return fire, or actual simulation of the game's AI so it will always be an approximation anyway.

Incidentally the question of how many non-discontinuous ships can exist in Starsector has been calculated by these folks:

https://iopscience.iop.org/article/10.1088/0305-4470/26/7/012

since the question is equivalent to how many snakes of length at most n can be made in the game Snake.

The number is this  http://oeis.org/A001411 (sum up to the largest permitted number of armor cells for the ship).

Edit: not actually true because we would consider two ships with the same configuration of armor cells the same ship even if they are positioned differently on the armor grid. Then we would get this: https://oeis.org/A037245
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 03, 2023, 10:15:17 PM
On a different note (double posting because this is very important) got a response from Alex who is officially Best Dev in history. He writes

Quote
I think it's actually "point guns with a highest possible total <value> at the target" where <value> is based on gun size and some other stuff like whether the gun needs to aim, is PD, has ammo remaining, some stuff about the target's state, etc.

When I asked about how guns would be pointed at an immobile unresponsive wall. It is more complex in real combat with various other parameters considered. Must ask about how value is calculated but it seems this is tractable.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Vanshilar on January 04, 2023, 03:09:11 AM
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

...
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.

The damage is at 0 DPS (and 0 slope) at the beginning of chargeup, and full strength DPS at the end of chargeup, with the same in reverse for chargedown. The hit strength stays constant throughout (at half of the full strength DPS). So the total damage is simply 1/3 of the full strength DPS times the time of the chargeup. So a beam with a chargeup of 2 and DPS of 300 will do a total of 2*300/3 = 200 damage, hit strength 150, over those 2 seconds.

Beam travel time is usually pretty fast. The slowest beams extend at 2400 su/s, with the fastest at 1000000 su/s (Paladin PD). Compare that with projectiles which are usually from 500 su/s to 1200 su/s. So I'm not sure if it's really worth modeling the extension speed of beams.

Beams are charging up in intensity as they extend. So in your example (1 second chargeup, 1 second travel time), the first tick would do 0 damage (since the beam is still extending), and then do full damage for a second, then do 1/3 damage for a second, all with a hit strength of DPS/2.

The idea you described sounds quite reasonable. However there is a big obstacle to implementing all of these: I have no idea how the ship's armor grid relates to its geometric shape in general terms. Vanshilar gave the horizontal size of armor cells but what about the vertical? And is there padding and how does it works? Anybody know about this?

The armor cells are squares. What the game does is have an invisible grid of armor cells overlaid with the ship's collision bounds. The grid extends into the empty space around ships. So generally speaking, you can assume that hits to armor/hull occurred on the polygon that makes up the ship's bounds, and thus to the related armor cells. There may occasionally be hits elsewhere (such as if a ship phase skims onto a projectile, or a fighter fires a missile "inside" of an enemy ship), but most of the damage will be on the collision bounds.

So if you're looking to model the shape of the target shape, it should be sufficient to look at the part of the collision bounds that the weapon can "see", then calculate the angular diameter of each armor cell that each part of the bounds is in, and that makes up the probability that each armor cell in that grid gets hit.

In general though I think you might want to consider whether or not the effort to make it more accurate is worthwhile. For example, generally speaking you're going to be looking for how to lay out the weapons for a frontal assault. Conquest of course is a broadside ship so it would be from the side. The enemy ship is not necessarily going to be perfectly facing you but you can approximate it as being fairly close. Etc. You can gradually work the analysis to be more accurate over time instead of trying to account for everything in the initial version.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 04, 2023, 04:26:53 AM
All right, thanks for explaining. I do think it is reasonable to go for a more limited model now. You really know this stuff. Do you have any knowledge of AI behavior? Specifically, which guns the AI prefers to fire at the target.

About the beam ticks, here is the question: don't beams tick every 1/10th of a second rather than in real time? So if, for example, the chargeup lasts 10 ticks, and the beam is at 0 strength at the start of it and full strength at the end of it, then rather than integral (0 to 1) x^2dx = 1/3, wouldn't it be the Riemann sum: sum(k=1 to 10) (((k-r)/10)^2), where r refers to which point you are calculating the intensity at? (if first tick's intensity is 1/10 then r=0 and the last tick is at intensity 1 for a total of 3.85 ticks and if it is 0/1 then r =1 and the last tick is at intensity 81/100 for a total of 2.85 ticks, and if we go for the midpoint the first tick is at intensity 1/20 and the last tick is at intensity 361/400, and the total ticks are 3.325). And of course we would approach 10/3 ticks as beam ticks approach continuous.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 04, 2023, 07:10:50 AM
I'm not sure how this idea could be done without a normal distribution, because isn't that where the enemy ship's random angle is assumed to come from?

The simplest way to avoid all of these issues is to model the enemy ship as a line as we have, and note in documentation that the enemy ship is modeled as a slab of armor with defensive statistics equal to enemy ship and rotation of the enemy ship is not considered. This is unsatisfying on a level, but on the other hand we are also not currently pursuing error correction to the armor, return fire, or actual simulation of the game's AI so it will always be an approximation anyway.

Good point.  Let's save the harder math for later if ever.

Quote
Incidentally the question of how many non-discontinuous ships can exist in Starsector has been calculated by these folks:

https://iopscience.iop.org/article/10.1088/0305-4470/26/7/012

since the question is equivalent to how many snakes of length at most n can be made in the game Snake.

The number is this  http://oeis.org/A001411 (sum up to the largest permitted number of armor cells for the ship).

Edit: not actually true because we would consider two ships with the same configuration of armor cells the same ship even if they are positioned differently on the armor grid. Then we would get this: https://oeis.org/A037245

Tricky, ain't it?  :o
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 04, 2023, 10:04:30 AM
I got the exact info from Alex. He says he thinks it is 1/3/7 weapon points for small/medium/large weapons and half points for PD weapons and the AI maximizes this value when pointing guns.

Note that he was very clear this is not a description of the decision making process in the real game as a variety of different factors are considered when rotating the ship in real combat. But it is the weapon choice logic.

We can implement this quite easily: instead of calculating max dps, calculate max weapon points by maximizing points for weapon * hit probability (ie. area under curve, the thing we are already calculating).

The final product should definitely also include an option to manually input the rotation as a player would use weapons differently, and to plot the DPS at angles graph. However, since this is the AI's logic, I am of the opinion that it should be our logic for comparing the strength of ships in general, as that is what those ships would do in combat in general (if they were fighting immobile slabs of metal and ignoring everything except the target, but you know).

Re: how many different connected spaceship armor grids can exist: it is actually not the same as snake, because snakes can't branch. It's this https://en.m.wikipedia.org/wiki/Polyomino . Amazingly enough no general formula is known, so we don't know how many ships you could make with 100 cells (but the number is humongous).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 04, 2023, 11:37:32 AM
I am surprised to learn Alex uses 'weapon points' rather than direct DPS calculation, though I suppose his approach is more flexible because it implicitly (though only presumptively) includes such non-DPS effects as On-Hit Effect.  I wonder how our algorithm would perform against his on the DPS-only vanilla weapons.  If we could beat his algorithm, I wonder if we could ask him to expose the relevant 'sockets' to the API for us to publish a mod.  ;D

A one-cell-thick armor slab as wide as the target is a good first approximation of its armor; a good first approximation of the width would be the difference between the greatest and least x coordinates of the bound vertices in the ship file.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 04, 2023, 01:34:50 PM
I haven't been following closely for a week or two because I've been busy with other things. Is all of this just to figure out what rotational orientation to put the ship in so that the most weapons/DPS are available?

I'm seeing stuff about random rotational states... that seems highly unnecessary.

If you want to get super detailed modeling rotational states, you can just copy the algorithm the game uses. Other than that, a fixed rotation state based on the loadout to maximize DPS or weapons points or whatever seems both relatively easy and reasonably realistic.

Also, I feel like for any ship with hardpoints (which is a large fraction of them), it's all pretty trivial anyway.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 04, 2023, 08:45:16 PM
Yeah, since the AI uses weapon points, then maximizing weapon points is the way to go. Don't we basically have all of the pieces to put it together then, Liral? If we are not adding depth to the enemy ship then we might just as well characterize our ship as pointlike, really, so use code as is.

I have a new idea about armor which involves this observation: you can reconstruct the armor state from pooled ADR adjusted damage to middle cells using simple rules (distribute the damage to the armor as usual). So, all information that is necessary to consider wrt armor must be coded in the armor starting parameters and damage to middle cells. Then if we can construct a description of ADR at middle cells in terms of ADR adjusted damage at middle cells we have a new way to describe armor (in terms of pooled damage, not an armor matrix). I'll let you know if it goes anywhere.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 04, 2023, 11:00:18 PM
Yeah, since the AI uses weapon points, then maximizing weapon points is the way to go. Don't we basically have all of the pieces to put it together then, Liral?

Note yet.

Deployment:
-Can freeze from MacOS to MacOS and, in principle, from anything to anything else

App:
-Opens a window that shows some text
-No user interface in the window

Database:
-Walks over all folders in mods
-Reads weapon_data.csv and ship_data.csv and converts their strings to data types
-Reads .ship files as .json

Analysis:
-Does a single weapon
-No shields
-Not fully object oriented

Quote
If we are not adding depth to the enemy ship then we might just as well characterize our ship as pointlike, really, so use code as is.

With weapon tracking arcs modeled, though, right?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 04, 2023, 11:19:02 PM
Yeah, with weapon tracking arcs using the code that we have. Basically you toss in the weapons that the ship is going to use, use the code to produce the hit distributions at optimum angle, and then use those hit distributions for the simulation. To save time, it would be reasonable to say that if a weapon has more than a 99.9% chance of missing (so, sum of the final and first cell of hit distribution is more than this) you discard it from the model.

I do not really know how to make a user interface or an application or what object oriented means so I am of limited help with these other problems. I do think it would be kind of interesting to learn and I even looked up a programming course but they literally made me choose between that and abstract algebra and well, uh, it's not really a fair contest, sorry. Some day!

Here is an idea of how I think the program should flow:

1. User modes: test single weapon, test single ship layout, test collection of weapons, test collection of layouts, diagnose weapon / layout.
2. Import data from Starsector files
Process for modes 1-4:
3. Select ship layout that we are using and target ship
4. For our ship, find the weapons that we are using, their facing and their tracking ability.
    Note if a weapon is in a hardpoint, since that will halve its spread.
    For target ship, find ship width, shield width, horizontal number of armor cells, armor stat, hull hp, max flux, flux dissipation, shield upkeep, and shield efficiency.
    Do we want the opponent to also have a layout including capacitors, vents and hullmods?
5. Using our code, find the weapon facing of choice for the AI (same method as optimum DPS but using weapon scores).
    Compute hit distributions at this facing. Discard weapons that have more than 99.9% chance to miss.
6. For the weapons we are keeping, compute shot time sequences.
7. Run the combat simulation vs. target ship in discrete time. For each second:
    - Calculate all incoming damage
    - Block it with shield if you are able to and increase flux by shield damage * shield effectiveness.
       If you did, dissipate only soft flux at rate flux dissipation - shield upkeep.
       If you did not, dissipate soft flux, then hard flux after soft flux, at rate flux dissipation.
    - If can't block with shield, deal damage to armor and then to hull.
    - End simulation when hull hits 0 and record time to kill.
8. Repeat for the requested collection of items to be tested and opponents. Print time to kill statistics for the requested collection of items.
Process for mode 5:
3. Using weapon data, calculate and print the DPS spread at angles with an SD of 0, and, if the user requests, the time to kill for that weapon vs some variety of ships.
Process for mode 6:
3. Using weapon and layout data data, calculate the DPS at angles for the layout with an SD of 0, as well as an indicator of the facing the AI would choose, and,
   if the user requests, the time to kill for that layout vs. some variety of ships.


There is, in fact, one more step. When the combat module is done, the adjustable parameter for SD should be calibrated vs. simulation data. It will likely be smaller than what I had due to our simulation being more accurate now.

However, you know much more about programs than I do, so I think it is smarter if you design what it should do and how it should go, and I'll help if I can!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 05, 2023, 11:52:16 AM
Good news.  I have made a clickable MacOS app!  I will try to have it run our tests when I click it.

Edit: It runs one of the tests and displays a result from it in a window, demonstrating the principle that the app can run our code and show the results.  Now to add fields and buttons.

Edit 2: I can't cross-compile to Windows easily.  Any suggestions would be appreciated.  Am on MacOS.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 05, 2023, 08:30:46 PM
Will it help if, when the source code is done, I compile it on Windows?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 05, 2023, 09:05:31 PM
Will it help if, when the source code is done, I compile it on Windows?

Oh, you have Windows?  Neat! :D  You would have to install Python, plus the project dependencies, and a code freezing tool, then run the freezing tool on the code from the command line.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 05, 2023, 09:08:43 PM
Well, I think I can manage that, so I can build it on Windows when that would be useful. Just tell me which things to install when the time comes.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 07, 2023, 12:18:36 AM
It occurs to me that it is fine to assume infinite flux capacity while testing weapons, but we may not wish to do so for ship variants. Unfortunately the current code can't really handle weapon flux as the time series is no longer dependent on weapon only but is dynamic and also dependent on weapon angle.

The logic is, of course, fire only those weapons that can hit. Only fire if you can afford to. Otherwise delay firing until you can. What makes it dynamic is there is only one flux pool but several weapons. And also ammo will regenerate even when weapons do not fire.

I wonder if this would be a task more easily realized with these objects you have mentioned rather than arithmetic?

The basic mode of operation should still be fine - generate a time sequence of gunfire up to the time limit. However must devise a way to account for flux pool.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 07, 2023, 03:10:25 AM
Good news!  I have made the app into a searchable database.  Type in your mod_id or 'vanilla', type in your identifier, toggle ships or weapons, and hit search to fill a scrollable box with all its stats, including the .ship file contents if it's a ship.

It occurs to me that it is fine to assume infinite flux capacity while testing weapons, but we may not wish to do so for ship variants. Unfortunately the current code can't really handle weapon flux as the time series is no longer dependent on weapon only but is dynamic and also dependent on weapon angle.

The logic is, of course, fire only those weapons that can hit. Only fire if you can afford to. Otherwise delay firing until you can. What makes it dynamic is there is only one flux pool but several weapons. And also ammo will regenerate even when weapons do not fire.

I wonder if this would be a task more easily realized with these objects you have mentioned rather than arithmetic?

What objects do you mean?  Shot, Ship, etc.?

Quote
The basic mode of operation should still be fine - generate a time sequence of gunfire up to the time limit. However must devise a way to account for flux pool.

For ship versus ship combat, we would have to iteratively increment time and recalculate until the end of the fight.  Supposing we could get away with 10 increments per second and an average fight of 100 seconds, we would need an average 1,000 increments per fight.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 07, 2023, 04:42:58 AM
Well, I meant object in the sense of updateable lists of things in a program which is what I understood it means. Such as for each gun you would keep track of its state (the options are charging up, firing, burst firing, burst delay, charging down, ready to fire, as well as another state of has infinite ammo / is at full ammo / is regenerating ammo).

Then, apply the following rules in increments of 1/20th of a second:
- Dissipate flux at the correct rate
- If a gun is ready to fire or burst firing and we have sufficient flux capacity to fire it then increase flux by flux per shot amount and, if it was in a state of ready to fire, switch it to charging up, and if it was in a state of burst firing, start burst delay timer to the next shot in the burst (possibly in order of weapon score?)
- If a gun has finished charging up then fire it and switch mode to either burst firing if it has a burst delay, or charging down. Record time of shot.
- If a gun has finished its burst delay then fire it and switch it to burst firing if there are burst shots left or charging down if not. Record time of shot.
- If a gun was charging down and has finished charging down, switch it to ready to fire
For guns that have ammo:
- If a gun previously had full ammo and now has less than full ammo start the gun's reload timer
- If a gun's reload timer has reached zero add ammo either to the gun (if reload size is 0 or 1) or to a clip, and add the clip's ammo to the gun if the reload size has been reached (if reload size is > 1).
- If a gun previously did not have full ammo and now does, stop reloading.

Collect all shots and compute the number of shots that are within each second for each gun to get time sequences in increments of 1 second as we have used previously. I mean for me I have no idea how to write a program like this but it seems simpler than doing it using equations or arithmetic cycles with a shared flux pool.

1/20th second is an acceptable increment because nothing in the game is allowed to fire faster than that, so it will never disadvantage a weapon.

Edit: I actually do know how to write this program using lists in R, so maybe can do it after all. Hm.

E2: so I started writing this and I noticed something interesting: we can express the state changes of any weapon as a unique sequence of integers that we loop over cyclically. For example if we define
Ready=1
Charging =2
Bursting=4
Burst delay=3
Chargedown=5

Then if a gun has a chargeup and chargedown of 0, and a burst size of 3, we can give it a cycle of 1,3,4,3,4 And if a gun has a chargeup and chargedown of 1 and burst size of 3, then we can give it a cycle of 1,2,3,4,3,4,5. In both cases the cycle describes the states of the gun so we only need to keep track of the gun's cycle and one timer until the next step in the cycle. (If the gun runs out of ammo mid-burst or we can't fire it due to flux then it goes to the last index of the cycle if that is 5 and otherwise to index 1)

To make this even more elegant we might write
Ready=4
Charging =5
Bursting=3
Burst delay=2
Chargedown=1

Then for example the cycle with a chargeup and a chargedown and a burst of 3 becomes
4,5,2,3,2,3,1. Gunfire happens exactly when there is such a state change that the next index in the cycle is lower than the previous.

E3: Alright here is some preliminary code. We define a "characteristic cycle" and a "reload cycle" for each gun (only the former for guns with no ammo). The code will loop over these with a modulo operation. The first row of the characteristic cycle gives the state that the gun is in and the second row gives how long that state will last until transition to the next state. The gun fires when it transitions to a cell that has a lower (or equal) value than where it was previously. For reloading, we have a 1 step cycle for guns with no reload size. The ion pulser, however, will regenerate 3 ammo at a time. So the reload cycle keeps track of how much ammo has been regenerated and the final value of row 1 will be inserted in the gun when the reload cycle transitions to a cell that has a lower value than where it was previously.

Code
#1. Constants
#the interval we are using in the combat simulation. Units of time are seconds.
time_interval <- 1
#how long a beam tick lasts
beam_tick <- 1/10
#the global minimum unit of time
global_minimum_time <- 1/20
#maximum duration of combat
time_limit <- 500
#gun states
READY <- 4
CHARGEUP <- 5
BURST <- 3
BURSTDELAY <- 2
CHARGEDOWN <- 1
#ammo states
UNLIMITED <- -1
FULL <- 1
REGENERATING <- 0
#gun modes
GUN <- 0
BEAM <- 1

#a weapon is a list containing the elements
#weapon name, chargeup, chargedown, burstsize, burstdelay, ammo, ammoregen, reloadsize, traveltime, mode,
#gun state, ammo state, gun mode

#gun_characteristic_cycle
#we can express the functioning of each gun as a unique cycle of integers using the above states.
#In addition, we pair these integers with times for the gun's operation
#input: a gun, in format of generate_gun
#output: the characteristic cycle of that gun, a 2 x n matrix with row 1 giving the cycle of the gun
# and row 2 giving the duration of each state.
gun_characteristic_cycle <- function(gun){
  vector <- vector(mode="double")
  vector <- c(vector,4)
  if(gun$chargeup > 0) vector <- c(vector, 5)
  if(gun$burstsize > 1 ){
    if(gun$burstdelay > 0){
      for(i in 1:(gun$burstsize-1)) vector <- c(vector, 2,3)
    }
  }
  if(gun$chargedown > 0) vector <- c(vector,1)
  matrix <- matrix(data=vector, nrow=2,ncol=length(vector),byrow = TRUE)
  for(i in 1:length(vector)){
    if(matrix[1,i] == 4) matrix[2,i] <- 0
    if(matrix[1,i] == 5) matrix[2,i] <- gun$chargeup
    if(matrix[1,i] == 3) matrix[2,i] <- 0
    if(matrix[1,i] == 2) matrix[2,i] <- gun$burstdelay
    if(matrix[1,i] == 1) matrix[2,i] <- gun$chargedown
  }
  return(matrix)
}
 
gun_reload_cycle <- function(gun){
  vector <- vector(mode="double")
  vector <- c(vector,1)
  if(gun$reloadsize > 1) for(i in 2:gun$reloadsize) vector <- c(vector,i)
  matrix <- matrix(data=vector, nrow=2,ncol=length(vector),byrow = TRUE)
  for(i in 1:length(vector)){
    matrix[2,i] <- 1/gun$ammoregen
  }
  return(matrix)
}


#Function generate_gun
#input: gun name, vector containing gun values
#example: chargeup = 0,chargedown=0, burstsize = 0.2, burstdelay = 0.1, ammo = 20, ammoregen = 1, reloadsize = 0, traveltime = 0, mode=BEAM, fluxpershot=10
#output: a list object containing all above mentioned parameters, plus parameters for
#gun's state
#ammo state
#burst shot
#state timer until next change of state
#ammo reload timer
#shots fired at once (1 if burstsize =1 or burstdelay >0, else burstsize)
generate_gun <- function(name, vector){
  starting_ammo_state <- 1
  if(vector[5] == -1) starting_ammo_state <- -1
  shots_fired_at_once <- 1
  if(vector[3] > 1) if(vector[4]==0) shots_fired_at_once <- vector[3]
  gun <- list(
    name=as.character(name),
    chargeup=as.double(vector[1]),
    chargedown=as.double(vector[2]),
    burstsize=as.integer(vector[3]),
    burstdelay=as.double(vector[4]),
    ammo=as.integer(vector[5]),
    ammoregen=as.double(vector[6]),
    reloadsize=as.integer(vector[7]),
    traveltime=as.double(vector[8]),
    mode=as.integer(vector[9]),
    flux_per_shot=as.double(vector[10]),
    gunstate=1,
    ammostate=starting_ammo_state,
    burst_shot=0,
    state_timer=0,
    ammo_reload_timer=0,
    shots_fired_at_once=shots_fired_at_once
  )
  gun <- list(
    name=as.character(name),
    chargeup=as.double(vector[1]),
    chargedown=as.double(vector[2]),
    burstsize=as.double(vector[3]),
    burstdelay=as.double(vector[4]),
    ammo=as.double(vector[5]),
    ammoregen=as.double(vector[6]),
    reloadsize=as.double(vector[7]),
    traveltime=as.double(vector[8]),
    mode=as.integer(vector[9]),
    flux_per_shot=as.double(vector[10]),
    gunstate=1,
    ammostate=starting_ammo_state,
    burst_shot=0,
    state_timer=0,
    ammo_reload_timer=0,
    shots_fired_at_once=shots_fired_at_once,
    characteristic_cycle=gun_characteristic_cycle(gun),
    reload_cycle=gun_reload_cycle(gun)
  )
  return(gun)
}

#ion pulser
#chargeup,chargedown, burstsize, burstdelay, ammo, ammoregen, reloadsize, traveltime, mode, fluxpershot

generate_gun("Ion Pulser",c(0.05,0.05,3,0.1,20,2,3,1,GUN,100))

Output
> generate_gun("Ion Pulser",c(0.05,0.05,3,0.1,20,2,3,1,GUN,100))
$name
[1] "Ion Pulser"

$chargeup
[1] 0.05

$chargedown
[1] 0.05

$burstsize
[1] 3

$burstdelay
[1] 0.1

$ammo
[1] 20

$ammoregen
[1] 2

$reloadsize
[1] 3

$traveltime
[1] 1

$mode
[1] 0

$flux_per_shot
[1] 100

$gunstate
[1] 1

$ammostate
[1] 1

$burst_shot
[1] 0

$state_timer
[1] 0

$ammo_reload_timer
[1] 0

$shots_fired_at_once
[1] 1

$characteristic_cycle
     [,1] [,2] [,3] [,4] [,5] [,6] [,7]
[1,]    4 5.00  2.0    3  2.0    3 1.00
[2,]    0 0.05  0.1    0  0.1    0 0.05

$reload_cycle
     [,1] [,2] [,3]
[1,]  1.0  2.0  3.0
[2,]  0.5  0.5  0.5


The way you account for flux and ammo is that states 3 and 4 are special: you can only proceed by spending flux and/or ammo. For the rest, switch state according to global time.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 07, 2023, 09:12:59 AM
For object oriented R, you can use reference classes (https://www.rdocumentation.org/packages/methods/versions/3.6.2/topics/ReferenceClasses) to avoid having to use lists for everything.

Code
GradStudent = setRefClass(
    'GradStudent',
    fields = list(
        GPA = "numeric", 
        classes = "list",
        mood = "character"
    ),
    methods = list(
        initialize = function(GPA, classes, mood) {
            GPA <<- GPA
            classes <<- classes
            mood <<- mood
        },
        study = function() {
            for (class in classes) {
                GPA <<- GPA + 0.001
            }
            mood <<- "tired"
        }
    )
)

gradStudent = GradStudent(2, list("english", "math"), "happy")
gradStudent
gradStudent$study()
print(gradStudent$GPA)
Reference class object of class "GradStudent"
Field "GPA":
[1] 2
Field "classes":
[[1]]
[1] "english"

[[2]]
[1] "math"

Field "mood":
[1] "happy"
[1] 2.002
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 07, 2023, 07:44:16 PM
More good news.  I have written code to determine the firing 'mode' (gun, missile, continuous beam, or burst beam) of a weapon from its data by coding the database to extract two relevant .wpn file fields alongside the usual weapon_data.csv.  I would have grabbed the entire .wpn file, but they are not valid JSON, so I had to settle for just the fields I needed.  To do the same for a Ship, I need to know how big the cells of an armor grid of the ship should be, but I haven't found any indication in the data.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 07, 2023, 08:46:46 PM
Great! Vanshilar has us covered wrt armor cells. I'll just quote his post below.

I have the gun flux code all written in my head, since we can use the same cycle structure for beams and the only modification is what counts as a shot and adding intensity adjustment. But there's a good chance I won't be able to sit down and write it in the next few days. We'll see.

By the way, I took a look at how the size of the armor cells are determined. The game looks only at the length of the ship sprite, but not the width:

If the length is < 150, then each armor cell is 15 pixels wide.
If the length is between 150 and 300, then each armor cell is 1/10 the length.
If the length is > 300, then each armor cell is 30 pixels wide.

For reference, an Enforcer is 136 pixels long, a Hammerhead is 164 pixels long, an Aurora is 280 pixels long, and a Legion is 308 pixels long. Testing was done by looking at the lower left green icon in combat of an Atlas whose length and width were set to different amounts in atlas.ship.

This has some interesting implications. Since each armor cell is given 1/15 of the base armor rating, then having smaller armor cells is better, since it means those armor points are more densely packed. Assuming that shots tend to hit in different spots, rather than in the same spot over and over, then this could mean as much as a factor of 2 difference in how much armor the incoming fire needs to remove before hitting hull.

Also, since ships are roughly spherical, this means that "wider" ships are better in terms of taking armor damage, since they'll tend to have somewhat smaller armor cells than "longer" ships. However, this is countered by the fact that "wider" ships provide a bigger frontal area that an opposing weapon could hit, which balances this out. So Alex made the right decision here (in basing armor cell size on length).

Taking a couple of examples, Enforcers are 136 pixels long, so their armor cells are 15 pixels wide. At 900 base armor, each armor cell holds 60 armor to start, so that's 4 armor per pixel. Dominators are 180 pixels long, so their armor cells are 18 pixels wide. At 1500 base armor, each armor cell holds 100 armor to start, so that's 5.56 armor per pixel. Onslaughts are 384 pixels long, so their armor cells are 30 pixels wide. At 1750 base armor, each armor cell holds 117 armor to start, so that's 3.89 armor per pixel. So it turns out, Dominators actually have the most concentrated armor protection. It won't help against focused fire, i.e. taking a Reaper hit or something, but over the course of a long battle, with incoming fire from multiple directions, the smaller armor cells means that it'll be able to absorb a surprising amount of damage since there's more armor packed into a given area.

(What the Onslaught has going for it however is that the nose is "jagged" meaning some shots will land more inward and others more outward, and the sides slope away, both of which increase the number of armor cells that can absorb the damage. So this helps it absorb more damage than a smooth or square shape. Not sure if Alex intentionally designed the Onslaught with this in mind, but this contributes to the Onslaught being able to take a lot of punishment.)

E: Okay I've tried layouts suggested by the model. And it has definitely improved my Conquest builds, but this needs an accuracy parameter. The stupid HACs that the model thinks are so good can't hit the broad side of a space station on a bad day (probably part of the balance on part of Alex) and yet the model gives them the same accuracy as all other weapons which has to be a problem. They are not actually as good at killing frigates as the model would predict for example.

Yeah my testing a while back (here (https://fractalsoftworks.com/forum/index.php?topic=25077.msg373121#msg373121)) was that, even with Gunnery Implants (best target leading and -25% recoil) and Ballistic Mastery (+33% projectile speed), something like the Mark IX misses around 24% of the time in actual combat. Heavy Autocannon has similar stats so it probably misses a similar amount of time. That's why I figured the model needs to have a wider distribution (wider than the armor) or some other means of accounting for misses. This is also why it makes sense that in testing, the target Dominator ended up taking ~8-9k of armor damage, since that was roughly the width of the ship (about 12 armor cells wide); the remaining shots would've missed, and hence it could be modeled as an armor band that would be ~17 cells wide.

Then in the model, the distribution's variance would be based on the weapon's inherent spread and some function of the weapon's speed and the target's maneuverability (slower projectile speed and higher target maneuverability means greater variance). In terms of weapons doing damage consecutively, easiest hack would be to base it on weapon speed, i.e. fastest weapon resolves first within each time interval.

Also, I'm not so sure about trying to model movement-based inaccuracy. Given actual relative movement with no spread, the shot location is not random, so it really should be a bias in the mean of the distribution rather than a variance. But if you try to model some distribution of possible relative movements, you could maybe come up with some distribution of shot locations from that. Although that would still depend on how the automatic aim leading and the AI works as well. All in all, I think it would be pretty tough to model accurately.

At that point, the shots are no longer independent of each other, so you're really looking at some sort of Markov Chain Monte Carlo simulation to accurately capture that. Too much effort for too little improvement in modeling accuracy. Also, if the mean is shifting, then it's just equivalent to the ship rotating, so the shots are more or less still seeing the same number of armor cells, just that where they're located physically on the ship is different. So I don't think moving the mean is worth doing here. Easier to just capture all that in the variance.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 08, 2023, 05:09:56 AM
All right, through sheer doggedness I was able to do the prototype code today, though I probably shouldn't have. This is also my first code using objects. The casualty to the time limit was documenting the code. But it works! I'll add the info later.

Sample run: data
gun1 = Gun("Ion Pulser",3,0.05,0.05,3,0.1,20,2,3,1,GUN,100)
gun2 = Gun("Ion Pulser",3,0.05,0.05,3,0.1,20,2,3,1,GUN,100)
beam = Gun("Tachyon Lance",7,0.5,1,1,4,-1,0,0,0,BEAM,2000)
ourship = OurShip(2000,600)

ie. parameters from the game, TL hits instantly, ion pulser shots take 1 sec to travel, our ship has a flux cap of 2000 and dissipates 600 flux per second.

Graphical output:
(https://i.ibb.co/TcZ4JDp/image.png) (https://ibb.co/rkgxqKT)

Code
 
#1. Constants
#the interval we are using in the combat simulation. Units of time are seconds.
time_interval <- 1
#how long a beam tick lasts
beam_tick <- 1/10
#the global minimum unit of time
global_minimum_time <- 1/20
#maximum duration of combat
time_limit <- 30
#gun states
READY <- 4
CHARGEUP <- 5
BURST <- 3
BURSTDELAY <- 2
CHARGEDOWN <- 1
#ammo states
UNLIMITED <- -1
FULL <- 1
REGENERATING <- 0
#gun modes
GUN <- 0
BEAM <- 1

characteristic_cycle <- function(chargeup,burstsize,burstdelay,chargedown,mode){
  vector <- vector(mode="numeric")
  if(mode==GUN){
  vector <- c(vector,4)
  if(chargeup > 0) vector <- c(vector, 5)
  if(burstsize > 1 ){
    if(burstdelay > 0){
      for(i in 1:(burstsize-1)) vector <- c(vector, 2,3)
    }
  }
  if(chargedown > 0) vector <- c(vector,1)
  matrix <- matrix(data=vector, nrow=2,ncol=length(vector),byrow = TRUE)
  for(i in 1:length(vector)){
    if(matrix[1,i] == 4) matrix[2,i] <- 0
    if(matrix[1,i] == 5) matrix[2,i] <- chargeup
    if(matrix[1,i] == 3) matrix[2,i] <- 0
    if(matrix[1,i] == 2) matrix[2,i] <- burstdelay
    if(matrix[1,i] == 1) matrix[2,i] <- chargedown
  }
  return(matrix)
  }
  if(mode==BEAM){
    vector <- c(vector,4)
    if(chargeup > 0) vector <- c(vector, 5)
    if(burstsize > 0 ) vector <- c(vector, 3)
    if(chargedown > 0) vector <- c(vector,1)
    if(burstdelay > 0) vector <- c(vector,2)
    matrix <- matrix(data=vector, nrow=2,ncol=length(vector),byrow = TRUE)
    for(i in 1:length(vector)){
      if(matrix[1,i] == 4) matrix[2,i] <- 0
      if(matrix[1,i] == 5) matrix[2,i] <- chargeup
      if(matrix[1,i] == 3) matrix[2,i] <- burstsize
      if(matrix[1,i] == 2) matrix[2,i] <- burstdelay
      if(matrix[1,i] == 1) matrix[2,i] <- chargedown
    }
    return(matrix)
  }
}

reload_cycle <- function(reloadsize,ammoregen){
  vector <- vector(mode="numeric")
  vector <- c(vector,1)
  if(reloadsize > 1) for(i in 2:reloadsize) vector <- c(vector,i)
  matrix <- matrix(data=vector, nrow=2,ncol=length(vector),byrow = TRUE)
  for(i in 1:length(vector)){
    matrix[2,i] <- 1/ammoregen
  }
  return(matrix)
}

Gun <- setRefClass(
  "Gun",
  fields=list(
    name="character",
    points="numeric",
    chargeup="numeric",
    chargeup_ticks="numeric",
    chargedown="numeric",
    chargedown_ticks="numeric",
    burstsize="numeric",
    burstdelay="numeric",
    burst_ticks="numeric",
    ammo="numeric",
    maxammo="numeric",
    ammoregen="numeric",
    reloadsize="numeric",
    traveltime="numeric",
    travel_ticks="numeric",
    mode="numeric",
    flux_per_shot="numeric",
    state="numeric",
    previous_state="numeric",
    stateindex="numeric",
    statetimer="numeric",
    reloadtimer="numeric",
    shots_fired_at_once="numeric",
    characteristiccycle="matrix",
    reloadcycle="matrix",
    reloadstate="numeric",
    reloadstateindex="numeric",
    previous_reloadstate="numeric",
    shot_times="data.frame"
  ),
  methods=list(
    initialize=function(name,points,chargeup,chargedown,burstsize,burstdelay,ammo,ammoregen,
                        reloadsize,traveltime,mode,flux_per_shot){
      name <<- name
      points <<- points
      chargeup <<- chargeup
      chargeup_ticks <<- round(chargeup/beam_tick_time,0)
      chargedown <<- chargedown
      chargedown_ticks <<- round(chargedown/beam_tick_time,0)
      burstsize <<- burstsize
      burstdelay <<- burstdelay
      burst_ticks <<- round(burstsize/beam_tick_time,0)
      ammo <<- ammo
      maxammo <<- ammo
      ammoregen <<- ammoregen
      reloadsize <<- reloadsize
      traveltime <<- traveltime
      travel_ticks <<- round(traveltime/beam_tick_time,0)
      mode <<- mode
      flux_per_shot <<- flux_per_shot
      state <<- 4
      previous_state <<- 1
      stateindex <<- 1
      statetimer <<- 0
      if(burstdelay == 0 & mode == GUN) shots_fired_at_once <<- burstsize else shots_fired_at_once <<- 1
      characteristiccycle <<- characteristic_cycle(chargeup,burstsize,burstdelay,chargedown,mode)
      if( ammo!= UNLIMITED) reloadcycle <<- reload_cycle(reloadsize,ammoregen)
      else reloadcycle <<- matrix(c(0,0),2,1)
      reloadstate <<- 1
      reloadstateindex <<- 1
      previous_reloadstate <<- 0
      reloadtimer <<- reloadcycle[2,1]
      shot_times <<- data.frame()
    },
    operationcycle = function(time){
      statetimer <<- statetimer-global_minimum_time
      if(statetimer <= 0){
        previous_state <<- state
        stateindex <<- ((stateindex) %% length(characteristiccycle[1,])) + 1
        state <<- characteristiccycle[1,stateindex]
        statetimer <<- characteristiccycle[2,stateindex] + statetimer
        if(mode==GUN){
          if(previous_state >= state) shot_times <<- rbind(shot_times, c(time+traveltime,shots_fired_at_once))
        }
        if(mode==BEAM){
          if(state==5){
            if(travel_ticks < chargeup_ticks){
            for(i in (1+travel_ticks):chargeup_ticks){
              shot_times <<- rbind(shot_times, c(time+(i-1)*beam_tick, ((i-1+0.5)/chargeup_ticks)^2*beam_tick))
            }
            }
          }
          if(state==3){
            if(travel_ticks < chargeup_ticks + burst_ticks){
            for(i in max(1+travel_ticks-chargeup_ticks,1):burst_ticks){
              shot_times <<- rbind(shot_times, c(time+(i-1)*beam_tick,beam_tick))
            }
            }
          }
          if(state==1){
            if(travel_ticks < chargeup_ticks + burst_ticks + chargedown_ticks){
            for(i in max(1+travel_ticks-chargeup_ticks-burst_ticks,1):chargedown_ticks){
              shot_times <<- rbind(shot_times, c(time+(i-1)*beam_tick,((chargedown_ticks-i+0.5)/chargedown_ticks)^2*beam_tick))
            }
            }
          }
        }
      }
    },
    ammo_cycle = function(){
      if(maxammo >= 1){
        reloadtimer <<- reloadtimer - global_minimum_time
        if(reloadtimer <= 0){
          reloadstateindex <<- ((reloadstateindex) %% length(reloadcycle[1,])) + 1
          previous_reloadstate <<- reloadstate
          reloadstate <<- reloadcycle[1,reloadstateindex]
          reloadtimer <<- reloadcycle[2,reloadstateindex]
          if(reloadstate < previous_reloadstate) ammo <<- ammo + previous_reloadstate
        }
        if(ammo >= maxammo) {
          ammo <<- maxammo
          reloadstate <<- 1
          reloadstateindex <<- 1
          previous_reloadstate <<- 0
          reloadtimer <<- reloadcycle[2,reloadstateindex]
        }
      }
    },
    lose_ammo = function(){
      ammo <<- ammo - shots_fired_at_once
    }
  )
)

OurShip <- setRefClass(
  "our_ship",
  fields=list(
    flux="numeric",
    maxflux="numeric",
    fluxdissip="numeric"
  ),
  methods=list(
    initialize=function(maxflux,fluxdissip){
      flux <<- 0
      maxflux <<- maxflux
      fluxdissip <<- fluxdissip
    },
    dissipate_flux = function(time){
      flux <<- max(0, flux - fluxdissip*time)
    },
    gain_flux = function(gainedflux){
      flux <<- flux+gainedflux
    }
  )
)

cycle <- function(gun, ship, time){
  if((gun$mode==0 & (gun$state == 3 | gun$state == 4)) | (gun$mode==1 & gun$state == 4)){
    if(ship$flux + gun$flux_per_shot * gun$shots_fired_at_once <= ship$maxflux){
      if(gun$ammo != 0){
        gun$lose_ammo()
        ship$gain_flux(gun$flux_per_shot * gun$shots_fired_at_once)
        gun$operationcycle(time)
        gun$ammo_cycle()
      } else { gun$ammo_cycle() }
    } else { gun$ammo_cycle() }
  } else {
    gun$operationcycle(time)
    gun$ammo_cycle()
  }
}

#    initialize=function(name,points,chargeup,chargedown,burstsize,burstdelay,ammo,ammoregen,
#reloadsize,traveltime,mode,flux_per_shot)
gun1 = Gun("Ion Pulser",3,0.05,0.05,3,0.1,20,2,3,1,GUN,100)
gun2 = Gun("Ion Pulser",3,0.05,0.05,3,0.1,20,2,3,1,GUN,100)
beam = Gun("Tachyon Lance",7,0.5,1,1,4,-1,0,0,0,BEAM,2000)
ourship = OurShip(2000,600)
time <- 0

df <- data.frame()

while(time < time_limit){
  ourship$dissipate_flux(global_minimum_time)
  cycle(beam, ourship, time)
  cycle(gun1, ourship, time)
  cycle(gun2, ourship, time)
  time <- time + global_minimum_time
  df <- rbind(df, c(time, gun1$ammo, gun2$ammo, ourship$flux))
}

colnames(df) <- c("time", "ion pulser 1 ammo", "ion pulser 2 ammo", "flux")
df[,2] <- df[,2]/max(df[,2])*100
df[,3] <- df[,3]/max(df[,3])*100
df[,4] <- df[,4]/max(df[,4])*100

df
library(ggplot2)
beamshots <- beam$shot_times
beamshots[,2] <- beamshots[,2]/max(beamshots[,2])*100
gun1shots <- gun1$shot_times
gun1shots[,2] <- gun1shots[,2]/max(gun1shots[,2])*100 -45
gun2shots <- gun2$shot_times
gun2shots[,2] <- gun2shots[,2]/max(gun2shots[,2])*100 -55

colnames(beamshots) <- c("time", "tachyon lance shot")
colnames(gun1shots) <- c("time", "ion pulser 1 shot")
colnames(gun2shots) <- c("time", "ion pulser 2 shot")
ggplot(df, aes(x=time,))+
  geom_line(aes(y=`flux`,color="Flux %"))+
  geom_line(aes(y=`ion pulser 1 ammo`,color="Ion pulser 1 ammo %"))+
  geom_line(aes(y=`ion pulser 2 ammo`,color="Ion pulser 2 ammo %"))+
  geom_point(data=beamshots,aes(x=time,y=`tachyon lance shot`,color="Tachyon lance shot %"))+
  geom_point(data=gun1shots,aes(x=time,y=`ion pulser 1 shot`,color="Ion pulser 1 shot"))+
  geom_point(data=gun2shots,aes(x=time,y=`ion pulser 2 shot`,color="Ion pulser 2 shot"))+
  labs(y="%")

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 08, 2023, 01:42:53 PM
More progress.  The app can now let a user create a ship from data and then holds onto that ship for them while they append weapons, likewise created, to an associated associated list, though without a slot limitation as yet.  It also displays the chosen ship and the list and lets the user replace the ship midway, erasing the associated list.  The next step would be to let the user choose an armor slab to target with those weapons and then add a button to simulate attacking the slab.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 08, 2023, 10:30:47 PM
Nice! I am still tying some loose ends in my "advanced firing sequence" code.

There is a question of how to prioritize weapons that compete for flux. We'll have to introduce some pseudo-AI for this. Here is the decision making algorithm I plan to implement

Priority 1: If you are firing any beams of limited burst size and weapon score 7, continue firing those beams (do not use flux to fire other weapons in such a way that it would prevent you from continuing firing these beams). This means that the pseudo-AI will finish firing a Tachyon Lance if it starts firing it, but will not hold on to firing a HIL for all eternity at the expense of other weapons.
Priority 2: Fire weapons of weapon score 7, in descending order of flux per shot, after that in whatever order the weapons were entered in (since it would be computationally expensive to loop through all permutations of orders instead and it really should not matter).
Priority 3: If you are firing any beams of limited burst size and weapon score 3.5, continue firing those beams.
Priority 4: Fire weapons of weapon score 3.5, in descending order of flux per shot, after that in whatever order the weapons were entered in
etc. for weapon scores 3, 1.5, 1 and 0.5 (those are all possible weapon scores).

I am also thinking of implementing venting. It would work like this: if your expected DPS after venting is greater than your current DPS times the duration of venting, then stop firing and vent (since in this case venting is always a net positive). since the AI does not consider DPS, then the logic should be: if you can fire weapons of a greater weapon score after venting, and you cannot fire those weapons currently, then stop firing and vent. I'm not sure if this is how the AI actually works though so maybe this is not something to implement, in fact.

Now is the time for comments, else I'll finish this as above described when I can.

This code will, when it's finished, also include a method to generate a second-by-second shot sequence from the shot time data like we use for the simulation.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 09, 2023, 09:44:11 AM
Thank you!  Woah, an AI?  That undertaking would be a big effort, which might become too big for us unless I should make a GitHub where we could write the AI and objects in Python together.  Otherwise I worry that I would have to refactor your R AI to call my Python Ship, Weapon, and Shot objects, or that I would have to refactor my Python objects for the translated R AI to call them, while we would somehow have to maintain equivalent R and Python codebases of those objects and the AI, translating future changes back and forth without version control, leading to madness.

Meanwhile, we haven't even yet tested a weapon generated from the database against an armor slab generated from the database or generated a ship with weapon slots filled from a variant or a user-interface.  Why don't we do that first? :D

Style Notes for Better R Regardless:
I have noticed you have used commented vectors to store information, which is not obvious without having memorized the commented association between vector index and attribute.  I could more easily translate and later refactor the code if you instead use named lists, which make this relationship obvious and these comments therefore unnecessary.

Code: Wrong
#armor, hull, max flux, flux dissipation
vector = c(150, 1000, 500, 50)
>> vector[1]
>> 150

Code: Right
ship_stats = list(armor = 150, hull = 1000, max_flux = 500, dissipation = 50)
>> ship_stats$armor
>> 150

Also some of your global constants (e.g., time_interval) are still not ALL_CAPS.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 09, 2023, 10:31:33 AM
Yeah true. Another thing I noticed is I defined integer constants at the start and then never used them because I thought of the states mostly as integers. Will fix in the final version.

I have a very conservative plan for this pseudo-AI-thing. Essentially it is just an object that has a list of the priorities of each gun as well as rules about how much flux it should hoard. I've changed the rules in my head a bit and now I think it should just keep track of how much flux to hoard for higher priorities and only fire lower priority guns if it can do so while keeping enough flux reserve.

So something like an object with a list of guns, a list of priorities for those guns, a method to decide priorities for those guns based on the guns' current status (it's just that beams in state 5, 3 or 1 are the highest priority, then guns with a higher weapon score, then guns with more flux per shot, after that maybe just random order is better than order of input) and then for each priority decide how much flux you must hoard for higher priorities and execute if you can do so while maintaining sufficient flux reserve.

Sounds complex but it's very simple in my head and probably actually easier than trying to build this order into the main loop.

Not a real AI, just a set of decision making rules about which guns to fire and methods to do so as an object of its own, in short.

Anyway you can forge ahead as much with putting together the rest as you like, since the final output of this will be time sequences adjusted for having a flux cap. So if the code works for time sequences in the format we have now it will work for the output of this as well.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 09, 2023, 11:07:02 AM
The big secret is that many things considered 'AI' are just big lists of rules. (The rest of the things considered 'AI' are just fancy curve fitting of data :P ).

The hard part is having the right rules to get the results you want. If you are happy to accept very simple rules and corresponding results, it's really not that complicated. In this case, I think some very simple rules can give 'good enough' results.

Also there should absolutely be a centralized GitHub to coordinate stuff. I've said this before, but I'm happy to contribute more code, I just don't want to be worrying about trying to coordinate my code with other peoples code in different languages, and I don't want to have to write redundant code to test things on my end and then change everything in order to fit that code into other structures.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 09, 2023, 01:12:43 PM
I have a very conservative plan for this pseudo-AI-thing. Essentially it is just an object that has a list of the priorities of each gun as well as rules about how much flux it should hoard. I've changed the rules in my head a bit and now I think it should just keep track of how much flux to hoard for higher priorities and only fire lower priority guns if it can do so while keeping enough flux reserve.

So something like an object with a list of guns, a list of priorities for those guns, a method to decide priorities for those guns based on the guns' current status (it's just that beams in state 5, 3 or 1 are the highest priority, then guns with a higher weapon score, then guns with more flux per shot, after that maybe just random order is better than order of input) and then for each priority decide how much flux you must hoard for higher priorities and execute if you can do so while maintaining sufficient flux reserve.

Sounds complex but it's very simple in my head and probably actually easier than trying to build this order into the main loop.

Not a real AI, just a set of decision making rules about which guns to fire and methods to do so as an object of its own, in short.

Anyway you can forge ahead as much with putting together the rest as you like, since the final output of this will be time sequences adjusted for having a flux cap. So if the code works for time sequences in the format we have now it will work for the output of this as well.

The problem isn't the outputs as much as the inputs and future changes, which are likely because we will see the flaws of this model and want to improve it.  Should you ever want to adjust any of the features, you would have to change the R version, which I might have to retranslate entirely.  Every call to a weapon object, ship object, etc., adds another dependency on the Python code, which you could instead even write yourself if I could just finish the Ship, Weapon, Shot, etc. objects first, make them work together, and then publish them on GitHub for intrinsic_parity and you to edit and work with.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 09, 2023, 01:21:23 PM
The big secret is that many things considered 'AI' are just big lists of rules. (The rest of the things considered 'AI' are just fancy curve fitting of data :P ).

The hard part is having the right rules to get the results you want. If you are happy to accept very simple rules and corresponding results, it's really not that complicated. In this case, I think some very simple rules can give 'good enough' results.

Also there should absolutely be a centralized GitHub to coordinate stuff. I've said this before, but I'm happy to contribute more code, I just don't want to be worrying about trying to coordinate my code with other peoples code in different languages, and I don't want to have to write redundant code to test things on my end and then change everything in order to fit that code into other structures.

GitHub created! https://github.com/LiralPolity/StatSector
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 09, 2023, 06:12:58 PM
I was thinking about a bit more of a sophisticated simulation code structure than just a script with all the info hard coded. Something like a simulation object that contains all of the necessary info and has a 'run simulation' method, and things like 'add_ship' and 'set_loadout' methods etc.

My only concern is that that could add some overhead if you needed to run lots of simulations.

Also, yay GitHub!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 09, 2023, 07:33:49 PM
The simulation is just a hardcoded script because I wanted to keep it simple and testable while CapnHector was figuring out the armor damage math and I was implementing it, and because we still haven't decided what simulation we want to run.  The project also has a database of every ship or weapon in the Starsector /data and /mods folders, alongside a graphical user interface that lets you search for ships and weapons by source and id, see their loaded data, and instantiate a ship and load it with weapons.  Try running the main method of the main .py file.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 09, 2023, 08:03:09 PM
I have a very conservative plan for this pseudo-AI-thing. Essentially it is just an object that has a list of the priorities of each gun as well as rules about how much flux it should hoard. I've changed the rules in my head a bit and now I think it should just keep track of how much flux to hoard for higher priorities and only fire lower priority guns if it can do so while keeping enough flux reserve.

So something like an object with a list of guns, a list of priorities for those guns, a method to decide priorities for those guns based on the guns' current status (it's just that beams in state 5, 3 or 1 are the highest priority, then guns with a higher weapon score, then guns with more flux per shot, after that maybe just random order is better than order of input) and then for each priority decide how much flux you must hoard for higher priorities and execute if you can do so while maintaining sufficient flux reserve.

Sounds complex but it's very simple in my head and probably actually easier than trying to build this order into the main loop.

Not a real AI, just a set of decision making rules about which guns to fire and methods to do so as an object of its own, in short.

Anyway you can forge ahead as much with putting together the rest as you like, since the final output of this will be time sequences adjusted for having a flux cap. So if the code works for time sequences in the format we have now it will work for the output of this as well.

The problem isn't the outputs as much as the inputs and future changes, which are likely because we will see the flaws of this model and want to improve it.  Should you ever want to adjust any of the features, you would have to change the R version, which I might have to retranslate entirely.  Every call to a weapon object, ship object, etc., adds another dependency on the Python code, which you could instead even write yourself if I could just finish the Ship, Weapon, Shot, etc. objects first, make them work together, and then publish them on GitHub for intrinsic_parity and you to edit and work with.

Okay, sure you can build these first. However, the code here will likely contain a variety of fields (things like current weapon state, my current version also has a beam tick count to keep track of what tick we are on to compute intensity and permit flux calculations tick by tick rather than up front like the prototype, and of course the weapon cycle descriptions which are very convenient) that are not present in the object read from data, so these will likely be derived from the data containing object, unless all of these fields will be initialized when reading the data I guess.

It really does need the AI, because if it never attempts to save flux it will do what my graphed run above did, that is, maxes itself using tach lance, but then can not immediately fire it again (or even worse in the new version with tick by tick flux, stops firing it midway) because it is maxing itself on flux from Ion Pulsers since these have lower flux per shot and can be fired first. It only gets back to the tach lance when the Pulsers are out of ammo and it can get a more sustained dissipation going in the graph above.

Of course the logic could also be implemented using logical statements in the loop, but that gets really complicated (I was actually going that way first but it becomes like a maze) and I think an AI object will be simpler.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 09, 2023, 08:17:39 PM
Okay, sure you can build these first. However, the code here will likely contain a variety of fields (things like current weapon state, my current version also has a beam tick count to keep track of what tick we are on to compute intensity and permit flux calculations tick by tick rather than up front like the prototype, and of course the weapon cycle descriptions which are very convenient) that are not present in the object read from data, so these will likely be derived from the data containing object, unless all of these fields will be initialized when reading the data I guess.

It really does need the AI, because if it never attempts to save flux it will do what my graphed run above did, that is, maxes itself using tach lance, but then can not immediately fire it again (or even worse in the new version with tick by tick flux, stops firing it midway) because it is maxing itself on flux from Ion Pulsers since these have lower flux per shot and can be fired first. It only gets back to the tach lance when the Pulsers are out of ammo and it can get a more sustained dissipation going in the graph above.

Of course the logic could also be implemented using logical statements in the loop, but that gets really complicated (I was actually going that way first but it becomes like a maze) and I think an AI object will be simpler.

I'm not saying that we don't need an AI, or that the objects won't need improvements or modifications, but that because we need an AI and that it, alongside the objects, will need changes, we should write our future code on GitHub in Python rather than on the forum in R and then Python.  We could have open issues, tickets, automated tests, automated builds, an API, and more.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 09, 2023, 08:40:28 PM
All right, let's wait until we're there then. Let me know when you think the time is ripe to proceed. Nice that the GitHub is going, thanks for making it; let's see if we can get other folks contributing.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 10, 2023, 12:30:21 PM
All right, let's wait until we're there then. Let me know when you think the time is ripe to proceed. Nice that the GitHub is going, thanks for making it; let's see if we can get other folks contributing.

We're almost there.  I need to know how to distribute the hit probability of each weapon aboard the shooter ship across the target ship.  The GUI lets you create both and arm the former.   
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 10, 2023, 08:15:18 PM
To find the probability of hitting each cell, use the code we built above (the optimum angle code) which outputs it, but instead of DPS use the following multipliers in the sum auc calculation: large weapons, 7, medium weapons, 3, small weapons, 1, point defense weapon, 0.5* score eg large PD, 3.5.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 11, 2023, 06:11:44 AM
To find the probability of hitting each cell, use the code we built above (the optimum angle code) which outputs it, but instead of DPS use the following multipliers in the sum auc calculation: large weapons, 7, medium weapons, 3, small weapons, 1, point defense weapon, 0.5* score eg large PD, 3.5.

Can do!
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 12, 2023, 09:51:38 PM
I have good news.  Ships now have weapon slots, to each of which a weapon can be assigned depending on the size and type of the slot and weapon, respectively, and the app lets you assign weapons manually, albeit only to the first slot of the same size.   Now, I can modify the main method of that analysis code you've mentioned to return rather than just print their hit distributions and then assign each one to the corresponding weapon.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 12, 2023, 09:59:46 PM
Sounds like you have exactly what we want and this is taking shape rapidly! Unfortunately my personal potato died and is gone for the moment and I do not feel it would be appropriate to write Starsector stuff on my protected research laptop or in a university lab, so I will be out of the picture for some days at least. I guess I could get a third one, some used piece of junk that could run such as minimal Linux for fun computer hacking, but since there is in fact research work to do, maybe later in life.

Anyway keep us posted on progress! I think this will be publishable without a flux management AI, just note the infinite flux assumption and that we will fix it later.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 13, 2023, 11:21:47 AM
Thanks!  Oh no, your potato!  If your potato was a desktop, then for under $100 you could replace it with a new micro-computer, and should you need more resources, many free cloud servers let you write-and-run code directly.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 13, 2023, 04:25:07 PM
More good news.  I have changed the analysis code to run on weapon, ship, and armor grid objects rather than rows of data.  Some bad news: I can't get it to work. 
Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
from math import pi



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))


def probability_hit_between(
        lower_bound: float,
        upper_bound: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit between two coordinates.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    return (probability_hit_within(upper_bound, standard_deviation,
                                   uniform_distribution_width)
            - probability_hit_within(lower_bound, standard_deviation,
                                     uniform_distribution_width))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / pi


def minimum_mean(spread: float, angle: float, arc: float) -> float:
    """
    Return the minimum mean hit probability of a weapon in a slot.

    spread - of the weapon
    angle - of the slot
    arc - of the weapon slot
    """
    return angle - (spread - arc) / 2


def maximum_mean(spread: float, angle: float, arc: float) -> float:
    """
    Return the maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    angle - of the slot
    arc - of the slot
    """
    return angle + (spread - arc) / 2


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - max(minimum_mean, min(maximum_mean, angle))


def upper_bounds(width: float, cells_across: int, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    width - pixel width of the ship
    cells_across - how many cells across the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * pi * distance
    ship_arc = width / c
    cell_arc = ship_arc / cells_across
    angles = [-ship_arc / 2]
    for i in range(cells_across): angles.append(angles[-1] + cell_arc)
    return tuple(angle * c for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_between(bounds[j-1], bounds[j],
                                            standard_deviation,
                                            spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 3) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]


def distributions(
        weapons: tuple,
        target: tuple,
        distance: float,
        standard_deviation: float):
    """
    Return for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_means.append(minimum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
            maximum_means.append(maximum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])

    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-359, 361))
 
    target_angular_size = arc_to_deg(target["width"], distance) / 4
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        weapon_score = 0
        for i, weapon in enumerate(weapons):
            angle = transformed_angle(target_positional_angle,
                                      minimum_means[i],
                                      maximum_means[i])
            probability = probability_hit_between(
                angle - target_angular_size,
                angle + target_angular_size,
                target_positional_angle_error,
                weapon["spread"])
            weapon_score += (WEAPON_SCORES[weapon["size"], weapon["pd"]]
                             * probability)
        weapon_score_at_angles.append(weapon_score)
 
    #now, note that angle -180 is just angle 180, angle -359 is angle
    #1, and so on, so
    #these must be summed with angles -179 to 180
    for i in range(180):
        weapon_score_at_angles[i+360] += weapon_score_at_angles[i]
    #likewise note that angle 360 is just angle 0, angle 359 is angle
    #-1, and so on
    for i in range(540, 720):
        weapon_score_at_angles[i-360] += weapon_score_at_angles[i]
 
    #having summed, select angles -179 to 180
    weapon_score_at_angles = weapon_score_at_angles[181:540]
   
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)
   
    import matplotlib.pyplot as plt
    plt.scatter(x_axis, weapon_score_at_angles)
   
    optimum_angle_index = middle_index_of_approximate_maxima(
        weapon_score_at_angles)
    optimum_angle = x_axis[optimum_angle_index]
    print("Optimum Angle:", optimum_angle)
    print()
    bounds = upper_bounds(target["width"], len(target.armor_grid.cells[0]),
                          distance)
    print("Bounds")
    print(tuple(map(round, bounds)))
    print()
    distributions = []
    for i, weapon in enumerate(weapons):
        spread_distance = deg_to_arc(weapon["spread"], distance)
        print("means:", minimum_means[i], " | ", maximum_means[i])
        angle_difference = transformed_angle(optimum_angle,
                                             minimum_means[i],
                                             maximum_means[i])
        print("angle difference:", angle_difference)
        adjustment = deg_to_arc(angle_difference, distance)
        print("adjustment:", adjustment)
        adjusted_bounds = tuple(bound + adjustment for bound in bounds)
        print("AB", tuple(round(bound, 3) for bound in adjusted_bounds))
        distributions.append(hit_distribution(adjusted_bounds,
                                              standard_deviation,
                                              spread_distance))
        print()
    print("Distributions")
    for distribution in distributions:
        print(tuple(round(p, 3) for p in distribution))
    return distributions

if __name__ == "__main__":
    #testing section - not to be implemented in final code
    #print a graph of the distribution and our choice of angle
    #plot(dps_at_angles, x=x_axis)
    #abline(v=optimum_angle)

    #Test ship and weapons.

    #We will create a data frame containing 18 weapons for testing.
    #Weapons are formatted as "name", dps, facing, tracking arc, spread
    #first, 6 weapons with easy parameters to parse
    simple_weapons = (["right phaser", 100.0, -10.0, 20.0, 5.0],
                      ["left phaser", 100.0, 10.0, 20.0, 5.0],
                      ["pd gun 1", 30.0, -160.0, 20.0, 0.0],
                      ["pd gun 2", 30.0, 180.0, 20.0, 0.0],
                      ["pd gun 3", 30.0, 160.0, 20.0, 0.0],
                      ["photon torpedo", 120.0, 90.0, 0.0, 5.0])

    #then, 12 weapons with randomly generated data
    #the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
    random_weapons = (["bb gun",5,-150,11,20],
                      ["space marine teleporter", 78,69,173,29],
                      ["turbolaser", 92,122,111,9],
                      ["hex bolter", 24,-136,38,20],
                      ["singularity projector", 95,28,122,25],
                      ["subspace resonance kazoo", 68,-139,12,2],
                      ["left nullspace projector", 10,28,54,0],
                      ["telepathic embarrassment generator", 30,-31,35,8],
                      ["perfectoid resonance torpedo", 34,72,10,17],
                      ["entropy inverter gun",78,-60,13,24],
                      ["mini-collapsar rifle", 27,28,16,13],
                      ["false vacuum tunneler", 32,78,157,20])

    #We will test against a ship formatted in the normal format
    target = (14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str):
            return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str):
            return self._data[name]

    class TestArmorGrid:
        def __init__(self, width: int):
            self.cells = [[i for i in range(width)]]
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str):
            return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "SMALL", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "SMALL", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "SMALL", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "SMALL", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "SMALL", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "SMALL", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12)
   
    distributions(test_weapons, test_target, 1000, 50)
[close]
Result
Optimum Angle: 168

Bounds
(-110, -92, -73, -55, -37, -18, 0, 18, 37, 55, 73, 92, 110)

means: -2.5  |  -17.5
angle difference: 170.5
adjustment: 2975.7863746503317
AB (2865.786, 2884.12, 2902.453, 2920.786, 2939.12, 2957.453, 2975.786, 2994.12, 3012.453, 3030.786, 3049.12, 3067.453, 3085.786)

means: 17.5  |  2.5
angle difference: 150.5
adjustment: 2626.720524251466
AB (2516.721, 2535.054, 2553.387, 2571.721, 2590.054, 2608.387, 2626.721, 2645.054, 2663.387, 2681.721, 2700.054, 2718.387, 2736.721)

means: -150.0  |  -170.0
angle difference: 318.0
adjustment: 5550.147021341968
AB (5440.147, 5458.48, 5476.814, 5495.147, 5513.48, 5531.814, 5550.147, 5568.48, 5586.814, 5605.147, 5623.48, 5641.814, 5660.147)

means: 190.0  |  170.0
angle difference: -22.0
adjustment: -383.9724354387525
AB (-493.972, -475.639, -457.306, -438.972, -420.639, -402.306, -383.972, -365.639, -347.306, -328.972, -310.639, -292.306, -273.972)

means: 170.0  |  150.0
angle difference: -2.0
adjustment: -34.906585039886586
AB (-144.907, -126.573, -108.24, -89.907, -71.573, -53.24, -34.907, -16.573, 1.76, 20.093, 38.427, 56.76, 75.093)

means: 162.5  |  77.5
angle difference: 5.5
adjustment: 95.99310885968812
AB (-14.007, 4.326, 22.66, 40.993, 59.326, 77.66, 95.993, 114.326, 132.66, 150.993, 169.326, 187.66, 205.993)

Distributions
(1.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
(0.002, 0.004, 0.01, 0.021, 0.04, 0.067, 0.099, 0.128, 0.144, 0.142, 0.123, 0.093, 0.062, 0.067)
(0.427, 0.096, 0.095, 0.09, 0.081, 0.068, 0.053, 0.038, 0.025, 0.015, 0.008, 0.004, 0.002, 0.001)
[close]

Here, I have tried a simpler version of just replacing the original code's DPS numbers with weapon values and found that the graph becomes nice, so I suspect I've made a mistake.  Before I should try again, would you mind checking my work for mistakes?
Result of Just Changing DPS to Weapon Score of 7
Bounds: (-110, -92, -73, -55, -37, -18, 0, 18, 37, 55, 73, 92, 110)

['right phaser', 7, -10.0, 20.0, 5.0, -17.5, -2.5]
(1.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0)

['left phaser', 7, 10.0, 20.0, 5.0, 2.5, 17.5]
(1.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0)

['pd gun 1', 7, -160.0, 20.0, 0.0, -170.0, -150.0]
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)

['pd gun 2', 7, 180.0, 20.0, 0.0, 170.0, 190.0]
(0.005, 0.009, 0.02, 0.039, 0.066, 0.098, 0.126, 0.144, 0.143, 0.124, 0.094, 0.063, 0.037, 0.032)

['pd gun 3', 7, 160.0, 20.0, 0.0, 150.0, 170.0]
(0.014, 0.019, 0.038, 0.064, 0.096, 0.125, 0.143, 0.143, 0.125, 0.096, 0.064, 0.038, 0.019, 0.014)

['photon torpedo', 7, 90.0, 0.0, 5.0, 90.0, 90.0]
(1.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 13, 2023, 10:38:03 PM
Well, copypasting my original code into rdrr.io (working on my phone) we get this
code
Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, -10, 20, 5)
weapon2 <- c("left phaser", 7, 10, 20, 5)
weapon3 <- c("pd gun 1", 7, -160, 20, 0)
weapon4 <- c("pd gun 2",7, 180, 20, 0)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 90, 0, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    return(c(
      weapon[3]-weapon[4]/2+weapon[5]/2,
      weapon[3]+weapon[4]/2-weapon[5]/2
    ))
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}


#1. Transform hit coordinate
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. max( minmean, min( maxmean, angle ) )
#4. Given that angle is the angle of the target relative to our ship, output is the angle that the weapon
#will assume as it tries to target the target

transform_hit_coord <- function(angle, weapon) return(max(weapon$min_mean, min(weapon$max_mean,angle)))

#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship

sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below -359 or above +360
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth/2
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth/2
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }

  #now, for angles -359 to 360 (all possible signed angles, calculate dps)
  angles <- seq(-359,360)
 
  dps_at_angles <- angles
  for (i in 1:720) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #now, note that angle -180 is just angle 180, angle -359 is angle 1, and so on, so
  #these must be summed with angles -179 to 180
  for (i in 1:180) dps_at_angles[i+360] <- dps_at_angles[i+360]+dps_at_angles[i]
  #likewise note that angle 360 is just angle 0, angle 359 is angle -1, and so on
  for (i in 540:720) dps_at_angles[i-360] <- dps_at_angles[i-360]+dps_at_angles[i]
 
  #having summed, select angles -179 to 180
  dps_at_angles <- dps_at_angles[181:540]
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                        [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
 
}

#sample runs
main(ship, 1000, 50, weapons[1:6,])

[close]

Output

[1] "right phaser:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "left phaser:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "pd gun 1:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "pd gun 2:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "photon torpedo:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser      7    -10           20      5    -17.5     -2.5
2    left phaser      7     10           20      5      2.5     17.5
3       pd gun 1      7   -160           20      0   -170.0   -150.0
4       pd gun 2      7    180           20      0    170.0    190.0
5       pd gun 3      7    160           20      0    150.0    170.0
6 photon torpedo      7     90            0      5     90.0     90.0

Image output:
(https://i.ibb.co/tCNMY7M/markup-clip-3005620315414770588.png) (https://imgbb.com/)

So we can see that there is an error in the original code because PD gun 2 should be able to hit here and in fact does according to the graph.

The image output tells us immediately what is wrong, that is why I am a fan of these: it is selecting the correct angle but giving the wrong distributions. I suspect this is because we did not wrap around with regard to the ship when computing the distributions even though we did in the angle selection function, so the former is still working with directional angles where 720!=0.

I do think your code is almost but not exactly correct, it just selects the other maximum. Editing my code so it selects the other maximum(graph:just imagine the vertical line at the other maximum) we get

[1] "right phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "pd gun 3:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "photon torpedo:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser      7    -10           20      5    -17.5     -2.5
2    left phaser      7     10           20      5      2.5     17.5
3       pd gun 1      7   -160           20      0   -170.0   -150.0
4       pd gun 2      7    180           20      0    170.0    190.0
5       pd gun 3      7    160           20      0    150.0    170.0
6 photon torpedo      7     90            0      5     90.0     90.0


I highly recommend including graphs with the numbers as that makes this easier. I'll unfortunately not be immediately able to work on the wraparound problem.

Edit to add: just thinking through it, I think the fix is simply to use min((abs(optimum angle), abs(360-optimum angle)) for the enemy ship position in the distribution at optimum angle function. This is because we have two ways of describing a position in terms of directional (winding) angles. For example let's say we have the angle -90. We get there from 0 by either +270 or -90 (or of course same +-n*360, but we do not need to consider this as we know both weapons and the optimum angle are between -179 and +180). Now say that we want to express the angle from -90 to -85. Clearly it is either -355 or +5. For the purposes of the weapons which must use these signed angles for the distr function to be correct (the PDF and CDF are not periodic functions) we should use +5, so the one with minimum absolute value. Now let's say we have a fixed weapon at +175 and a target at -175. Compute the transformed angle to get weapon angle - target angle = 350, so clearly the weapon can't hit at this angle if it can only hit, say, between -15 and +15 (angle from weapon to target). However 360-350 = 10, which is the real angle we should use. This problem only becomes apparent in our current code when we are near the angle +180 and the enemy is near -179 so it was good to find it now

Note that no changes are needed to the sum auc and other optimum angle finding functions, because we handled it there with the sum operation at the end. The equivalent here would be summing the hit distributions at the two alternative descriptions of position, but this does not work because we are also calculating the chance to miss and not just the chance to hit here (so we would end up with the probability distribution over cells adding up to more than 100% - for example 100% chance to miss and a 100% chance to hit cell 7). It would work (approximately - because the hit probabilities are near 0 for the wrong description of position) if we didn't have the cells that are misses though. But if you want to you could switch those to using this and get rid of the sum operation I think.

It is actually quite interesting to think about whether we could define the PDF and CDF for angles in the first place rather than signed angles / windings / directed angles / rotations / real numbers or whatever they are most accurately called. The CDF would need to be periodical, so that means the probability density function must be negative or undefined at some point (since it is the derivative of the CDF). So it seems a little problematic. But that's a bit of a tangent. This discussion does demonstrate the assumption here: we are assuming that tracking angle and spread are both maximally 360 degrees and the SD is pretty small so the distribution at larger angles is approximately zero. If it were the case that we were permitted, say, a 1080 degree spread, or such a large SD that weapons will likely hit regardless of target position, then the math here would fall apart as the operation I described above would not be permitted (we would have to seriously consider shots mapping from say -1200 degrees to -120 degrees). If it seems a little abstract what -1200 degrees means then imagine that there is a string tied to the gun that wraps around it as it rotates, but not one tied to the target. Then if we permitted arbitrarily large parameters we would have to consider the probability the target gets hit by the gun with the string wrapped around it ...1 times clockwise, 0 times, 1 times counterclockwise, 2 times counterclockwise...

Edit 2: it gets a little more complex because we need to rewrite the transform angle function. And I actually wrote most of it on my phone on rdrr.io but then I accidentally refreshed the page... get back to you on this.


Edit 3: I got it fixed. It is a relatively small change because I decided to do it using logic rather than worry about geometry and such.

These functions must be changed:
Code
min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2+weapon[[5]]/2,
      weapon[[3]]+weapon[[4]]/2-weapon[[5]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}

Code
transform_hit_coord <- function(angle, weapon){
  if(weapon$max_mean >= weapon$min_mean) return(max(weapon$min_mean, min(weapon$max_mean,angle)))
  if(angle < 0 & angle < weapon$max_mean) return(angle)
  if(angle >= 0 & angle > weapon$min_mean) return(angle)
  if(angle >= 0 & angle <= weapon$min_mean) return(weapon$min_mean)
  if(angle < 0 & angle >= weapon$max_mean) return(weapon$max_mean)
}

After doing this the sum operation at the end is no longer necessary and is in fact incorrect so remove it.

Code
  
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)

So the entire code is
Spoiler
Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, -10, 20, 5)
weapon2 <- c("left phaser", 7, 10, 20, 5)
weapon3 <- c("pd gun 1", 7, -160, 20, 0)
weapon4 <- c("pd gun 2",7, 180, 20, 0)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 90, 0, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2+weapon[[5]]/2,
      weapon[[3]]+weapon[[4]]/2-weapon[[5]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}

transform_hit_coord <- function(angle, weapon){
  if(weapon$max_mean >= weapon$min_mean) return(max(weapon$min_mean, min(weapon$max_mean,angle)))
  if(angle < 0 & angle < weapon$max_mean) return(angle)
  if(angle >= 0 & angle > weapon$min_mean) return(angle)
  if(angle >= 0 & angle <= weapon$min_mean) return(weapon$min_mean)
  if(angle < 0 & angle >= weapon$max_mean) return(weapon$max_mean)
}
#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship

sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below -359 or above +360
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth/2
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth/2
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }
 
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                          [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
 
}

#sample runs
main(ship, 1000, 50, weapons[1:6,])
[close]

Then, this is the output and it is clearly correct despite no funky sum hijinks, PD gun #2 can spin itself from -170 to +170.

[1] "right phaser:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "left phaser:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "pd gun 1:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "pd gun 2:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "photon torpedo:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser      7    -10           20      5    -17.5     -2.5
2    left phaser      7     10           20      5      2.5     17.5
3       pd gun 1      7   -160           20      0   -170.0   -150.0
4       pd gun 2      7    180           20      0    170.0   -170.0
5       pd gun 3      7    160           20      0    150.0    170.0
6 photon torpedo      7     90            0      5     90.0     90.0

(https://i.ibb.co/G5HsRz1/image.png) (https://imgbb.com/)

You can tell that we have fixed a bug that we should have noticed in the first place by looking at PD gun #2: in the uncorrected code its min mean is 170 and max mean is 190 which is illegal. In the correct version the min mean is 170 and max mean is -170. Note however that just fixing the max mean is not sufficient but you must also fix the transform hit coord function so it understands what to do when the max mean is negative and min mean is positive.

(If it feels confusing why the maximum should be less than the minimum then note it is the maximum ie. largest rotation CCW from the gun's perspective, not the ship's. They disagree about which is larger only when the gun rotates from 180 to -179 from the ship's point of view which is where the special cases happen)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 14, 2023, 01:53:27 PM
Glad to see you've found a way to code without your potato and seem to be enjoying it!   8)  Now that you've done some math and posted the code, I wonder if I should wait a little while because I have learned from experience that you might pass on some more edits...  :P
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 14, 2023, 08:16:33 PM
Oh no worries, I edited that post several times already over the course of a day and by now it also starts with one theory then goes on an abstract tangent then does something entirely different from what was originally proposed. So the process is complete. Don't have plans to change it more. Unless you noticed some problems?

By the way, you can fairly easily run my code for any comparisons by going to rdrr.io and running it online there.

Edit: well, actually maybe you know me better than I do, it seems like I tend to approach correct code iteratively and have an easier time seeing what is going on after posting for some reason. There are two more special cases: cases where the gun can spin around nearly but not exactly 360 degrees so both min and max mean are ipsilateral but reversed in order. This is handled using logic like so:

Code
transform_hit_coord <- function(angle, weapon){
#weapon arc does not include 180 to -179
  if(weapon$max_mean >= weapon$min_mean) {
 if(angle >= weapon$min_mean & angle <= weapon$max_mean) return(angle)
 if(min(abs(angle-weapon$max_mean),360-abs(angle-weapon$max_mean)) > min(abs(angle-weapon$min_mean),360-abs(angle-weapon$min_mean))) return(weapon$min_mean)
 return(weapon$max_mean)
}
#weapon arc includes 180 to -179 but does not cross 0
if(weapon$max_mean <= 0 & weapon$min_mean >= 0){
  if(angle < 0 & angle < weapon$max_mean) return(angle)
  if(angle >= 0 & angle > weapon$min_mean) return(angle)
  if(angle >= 0 & angle <= weapon$min_mean) {
if(abs(angle-weapon$min_mean) >= angle-weapon$max_mean) return(weapon$max_mean)
return(weapon$min_mean)
}
  if(angle < 0 & angle >= weapon$max_mean) {
if(abs(angle-weapon$min_mean) >= abs(angle-weapon$max_mean)) return(weapon$max_mean)
return(weapon$min_mean)
}
}
#weapon arc includes 0 and 180 to -179
  if(angle < 0 & angle <= weapon$min_mean & angle >= weapon$max_mean){
 if(abs(angle-weapon$max_mean) > abs(angle-weapon$min_mean)) return(weapon$min_mean)
 return(weapon$max_mean)
}
  if(angle >= 0 & angle <= weapon$min_mean & angle >= weapon$max_mean){
 if(angle-weapon$max_mean > abs(angle-weapon$min_mean)) return(weapon$min_mean)
 return(weapon$max_mean)
}
  return(angle)
}


There might be a much smarter way of doing this using classical geometry but my discrete math is so much stronger than my classical geometry (which is probably my weakest subject) that this is easier for me to write.

Anyway let me explain real quick what is going on here. These are rules of deduction for whether the target is within the gun's tracking arc or not, when we look at the angles from the ship's perspective.
1) if the max mean is greater than the min mean then it is very simple, we are in the tracking arc if we are between the max mean and min mean and figuring out which of the two is closer s very simple too must be done as below due to an edge case of being very close to +180/-179.
2) if the min mean is greater than 0 and the max mean is less than 0, then we are in the tracking arc if we are between min mean and +180, or between max mean and -179. If we are not in the tracking arc, we should deduce which is closer of the min and max mean by looking at the difference in angle from each.
3) if both are greater than 0 or less than 0, but the min mean is greater than the max mean, then we are in the tracking arc in all cases except if we are below the min mean but above the max mean. In that case deduce which is closer as above.

This really should be all of it, since there are only two special points: +180/-179, which is where the wraparound happens, and 0, where "maximum" ceases to mean closer to 0 and "minimum" means that instead, but those are both considered now. In retrospect I kind of wish I had selected angles from 0 to 359 instead of -179 to +180 but what can you do, as it is it's easier to just roll with it.

Sorry about poor style btw I typed this on my phone. Tested it on rdrr.io and it works. Full code:

Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, -10, 20, 5)
weapon2 <- c("left phaser", 7, 10, 20, 5)
weapon3 <- c("pd gun 1", 7, -160, 20, 0)
weapon4 <- c("pd gun 2",7, 180, 20, 0)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 90, 0, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2+weapon[[5]]/2,
      weapon[[3]]+weapon[[4]]/2-weapon[[5]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}

transform_hit_coord <- function(angle, weapon){
#weapon arc does not include 180 to -179
  if(weapon$max_mean >= weapon$min_mean) {
 if(angle >= weapon$min_mean & angle <= weapon$max_mean) return(angle)
 if(min(abs(angle-weapon$max_mean),360-abs(angle-weapon$max_mean)) > min(abs(angle-weapon$min_mean),360-abs(angle-weapon$min_mean))) return(weapon$min_mean)
 return(weapon$max_mean)
}
#weapon arc includes 180 to -179 but does not cross 0
if(weapon$max_mean <= 0 & weapon$min_mean >= 0){
  if(angle < 0 & angle < weapon$max_mean) return(angle)
  if(angle >= 0 & angle > weapon$min_mean) return(angle)
  if(angle >= 0 & angle <= weapon$min_mean) {
if(abs(angle-weapon$min_mean) >= angle-weapon$max_mean) return(weapon$max_mean)
return(weapon$min_mean)
}
  if(angle < 0 & angle >= weapon$max_mean) {
if(abs(angle-weapon$min_mean) >= abs(angle-weapon$max_mean)) return(weapon$max_mean)
return(weapon$min_mean)
}
}
#weapon arc includes 0 and 180 to -179
  if(angle < 0 & angle <= weapon$min_mean & angle >= weapon$max_mean){
 if(abs(angle-weapon$max_mean) > abs(angle-weapon$min_mean)) return(weapon$min_mean)
 return(weapon$max_mean)
}
  if(angle >= 0 & angle <= weapon$min_mean & angle >= weapon$max_mean){
 if(angle-weapon$max_mean > abs(angle-weapon$min_mean)) return(weapon$min_mean)
 return(weapon$max_mean)
}
  return(angle)
}
#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship

sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below -359 or above +360
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth/2
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth/2
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }
 
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                          [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
 
}

#sample runs
main(ship, 1000, 50, weapons[1:6,])

Edit: add a wraparound to the "which is closer" check because it is needed there too.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 15, 2023, 12:18:01 AM
Personally I would want to be using dot products of vectors to find angles between things. For instance, the dot product between the vector pointing from our ship to the target, and the vector pointing along the center of the weapon arc is equal to the cosine of the angle between those vectors, and if that angle is greater than 1/2 the weapon arc, then the target is not in the weapon arc. The definition of arccos where the domain is limited avoids all the 'wrap around' issues.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 15, 2023, 12:47:27 AM
That seems quite smart. Let's see how that would work. A weapon is pointing at 180 and the enemy ship is at -135 degrees. A vector pointing to the weapon is (-1,0) and one pointing to the enemy ship is (-1,-1). The dot product of these is 1. The length of vector 1 is 1 and of vector 2 is sqrt(2). The angle between vectors is arccos(1/sqrt(2)) = 45. Then if we assume that the weapon's tracking arc is 90 or more we are within it. If not then we calculate the angle between the ship and the upper and lower bounds of the tracking arc using the same logic and then return the one that is closer. So that is another way to do it. Can't be bothered to write it though because this already works but maybe that would have been cleaner since no special cases.

If Liral is feeling inspired to write this instead then here is how I would do the vector based algorithm in full noting that we use unit vectors:
1: find the unit vectors pointing to the enemy ship, to the gun's arc midpoint, max mean, and min mean (if the angle is alpha, they are (cos(alpha),sin(alpha))).
2: compute arccos of the dot product of arc midpoint (call it alpha) and target (call it theta), so arccos(sin(alpha)*sin(theta)+cos(alpha)*cos(theta)). If this is less than half of weapon's tracking arc, return theta
3: if that is not the case, repeat the process for the angles of the max mean and min mean, and return max mean if the angle to that is smaller, else min mean.

Well, actually having written it like that, it seemed to simplify things enough for Liral to be worth doing regardless, so here is the code and it produces the same output. Thanks i_p!
Code
transform_hit_coord <- function(angle, weapon){
  angle_rad <- angle * pi/180
  facing_rad <- weapon$facing * pi / 180
  angle_to_weapon <- acos(sin(angle_rad)*sin(facing_rad)+cos(angle_rad)*cos(facing_rad))*( 180 / pi )
  if(weapon$spread > weapon$tracking_arc) return(weapon$facing)
  if(angle_to_weapon <= (weapon$tracking_arc-weapon$spread)/2) return(angle)
  max_mean_rad <- weapon$max_mean * pi /180
  min_mean_rad <- weapon$min_mean * pi /180
  angle_to_min <- acos(sin(angle_rad)*sin(min_mean_rad)+cos(angle_rad)*cos(min_mean_rad))
  angle_to_max <- acos(sin(angle_rad)*sin(max_mean_rad)+cos(angle_rad)*cos(max_mean_rad))
  if(angle_to_max >= angle_to_min) return(weapon$min_mean)
  return(weapon$max_mean)
}

Note that the change to weapon min and max angles with the wraparound correction that I wrote above should still be made to the min_max_mean function. It is not necessary for this in fact, but it is still not good to have illegal values in a table in general.

Oh, almost forgot, documentation:

transform_hit_coord
input: an angle and a weapon, containing the cols facing, min mean and max mean
output: angle of the mean of the weapon's shot distribution as the weapon attempts to target the target
method: find the (unsigned) angle between angles (from the horizontal) alpha and beta by
arccos(sin(alpha)sin(beta)+cos(alpha)cos(beta))
find the angle from target to facing
If target is within tracking range/2 - spread/2 (since spread will kick the weapon away from the edge)
from facing weapon can track target and therefore returns target angle
Else returns whichever is closer to target, min mean or max mean
Special case: if spread>tracking range the weapon is poorly defined and can't track, because recoil can make the shot go anywhere despite tracking.
In this case return facing, the midpoint of the distribution
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 15, 2023, 04:59:34 PM
Also, the vector between ships is just the difference between the position vectors of the ships, and the magnitude of that difference vector is the range. So if you represent ship positions somewhere in the code (even if it is just static), that makes the calculations pretty trivial, and also sets up for considering movement in the future if we ever want to do that.

The vector pointing along the center of the weapon arc should also not be too complicated. I'm not sure how weapon arcs/mounts are defined in the simulation (or in the ship files), but if you know the angle to a vector from your coordinate system x axis, the corresponding unit vector is just [cos(angle) sin(angle)]. In general, you should be able to pre-define the weapon arc vector relative to a ship-fixed coordinate system, and then use the ship facing angle to rotate the vector into the global coordinate system (where the position vectors of the ships are defined) so that you can compute the dot product.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 15, 2023, 07:52:57 PM
Ship weapon slots are given as midpoint and arc, so no need to worry about that. So long as you know the unit vector in the direction of the enemy ship (or alternatively range to enemy ship and vector to enemy ship to calculate it) this is usable.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 15, 2023, 08:33:05 PM
Ship weapon slots are given as midpoint and arc, so no need to worry about that. So long as you know the unit vector in the direction of the enemy ship (or alternatively range to enemy ship and vector to enemy ship to calculate it) this is usable.
Presumably the weapons arcs are known with respect to the ship, which can rotate, while the positions of things are known in the global frame of reference. So you still need to get the vectors into a common coordinate system (accounting for the firing ships rotational state) before you can take the dot product.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 15, 2023, 09:40:42 PM
I don't really understand this point. Rotation doesn't alter angles between vectors, so if you have a vector from our ship to the enemy ship, and a vector from our ship to the gun's midpoint, then the angle between these will be the same regardless of whether you rotate them to align to a particular coordinate system or not. That is, unless there are multiple ships, you don't really need an absolute coordinate system as there is only one vector that is relevant which is from our ship to the target ship. Why would you describe the target ship in any other frame of reference than ours?

I suppose it would make sense if we were at some point to create a fully realistic simulation of the Starsector battlefield, since unlike real space it has boundaries and describing those would be easier in the absolute frame. If we ever get to a point where we want to make space non-infinite then we can do that and just rotate the vector to the enemy ship to adjust for our ship's rotation before applying this code.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 15, 2023, 10:07:29 PM
In the actual game, the positions of the ships are all known with respect to some global coordinate system which does not rotate or move with our ship (x,y coordinates on a grid fixed to the map). The weapon arcs are (presumably) known with respect to a coordinate system attached to your ship (angle with respect to the front of the ship or something). If you rotate your ship, the weapon arc moves with it (in the global coordinate system), but the direction to the target does not change. Or if you want to view it from your ship-fixed coordinate system, the vector to the (static) enemy moves when you rotate your ship but the weapon arc is fixed in that coordinate system.

If you are dealing with a completely static case, you could do stuff in a coordinate system attached to your own ship and just figure out what direction you want the target to be in relative to your ship at the start, but if your ship ever moves or rotates, that appears like your target moving in the ship-fixed coordinate system, and you would have to account for that, so it makes things much more difficult/complicated that just doing everything in a global coordinate system where the motions of the ships are independant.

It's also really not complicated math, so it seems like an easy thing to do that allows for more complex simulations in the future.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 15, 2023, 10:18:43 PM
It really shouldn't matter though unless space is bounded. If our ship moves forward x pixels and to the right y pixels, and the enemy ship moves forward r pixels and to the right s pixels, then in the system of coordinates centered on our ship we just say the enemy ship moves forward (r-x) pixels and to the right (s-y) pixels, right? And if our ship rotates we can easily express the rotation in polar coordinates. But it does get awkward if we have a boundary fixed in universal coordinates we must not cross.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 15, 2023, 10:44:28 PM
It's way more intuitive to me to work in global (inertial) coordinates. That way rotating my ship causes my weapons to point in a different direction, and the target does not move, whereas working in ship-fixed coordinates means when I rotate my ship, the target (and every other object) moves and my weapons stay stationary. It just seems silly to do things so that every time my ship rotates or moves, I have to update the position/orientation of every other object instead of just the state of my own ship and weapons. Particularly if you ever want to consider more than one ship.

But I guess being an engineer, I am just used to working in inertial (global) coordinates since that is where the relevant laws of physics are defined.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 15, 2023, 10:59:14 PM
Wait, aren't all physical laws independent of frame of reference?

The only university level physics I have is a course on relativity and reading about convolutions from a mathematical physics book so I genuinely have no idea about this stuff.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 16, 2023, 08:42:30 AM
Wait, aren't all physical laws independent of frame of reference?

The only university level physics I have is a course on relativity and reading about convolutions from a mathematical physics book so I genuinely have no idea about this stuff.
Newtons laws are definitely not. I never had to learn relativity, but according to the wikipedia page (https://en.wikipedia.org/wiki/Special_relativity) it's also only true in inertial (non-accelerating) frames. Doing simple classical mechanics (F=ma) in a moving (accelerating) frame results in fictitious forces (for example coriolis forces on earth).

It's easy to see how this is true, even in our little example here. If our ship is rotating and the target is stationary and has mass (m=/=0), then in our ship-fixed frame, the target can be accelerating (a=/=0) despite there being no forces acting on the target (F=0), so clearly F =/= ma in that frame.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 16, 2023, 09:01:27 AM
Yes, but isn't that just Newtonian mechanics being an incomplete theory? In relativity we have this stuff: https://en.m.wikipedia.org/wiki/Preferred_frame

I have absolutely no idea about other things than gravity or motion though (not that I have an idea of those either). We need a physicist in this. Well. Possibly not since we will probably not be including realistic physics in our model. Still, you'd think the "design philosophy" of laws of physics would be that switching the observer doesn't break them somehow. The alternative seems kind of weird.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 16, 2023, 02:08:13 PM
Isn't general relativity just about gravity and replaces newtons law of gravity? Not all of newtons laws. Like there is definitely a relativistic version of F=ma but I'm pretty sure it still requires a non-accelerating frame... I just asked my dad who taught physics and he agreed with that.

But this is all completely beside the point lmao. All I was saying is that everything in engineering is done in inertial frames because classical mechanics requires it, and no one is wasting their time with relativity unless they need it. Very few things reach a non-trivial fraction of c where relativity would be relevant.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 16, 2023, 02:27:44 PM
The laws of physics are the same in inertial and non-inertial reference frames by the equivalence principle (https://en.m.wikipedia.org/wiki/Equivalence_principle) of general relativity; unfortunately for our hopes of an intuitive and satisfying understanding of nature, this principle merely replaces the mind-bending implication that acceleration distorts the laws of physics with the mind-bending implication that acceleration distorts spacetime.

Centuries of theoretical, experimental, and lately computational research have yielded about a dense textbook's volume of fundamental equations accurately and precisely predicting experimental results, about a warehouse's worth of very convincing and even useful theories about what some of these equations mean, and absolutely no imminent prospect of any theory of all the equations recorded already, nevermind the ones we might later discover.  So for all practical purposes, use the fewest, simplest equations that accurately predict what you want to know, assuming whatever makes applying those equations the most convenient without spoiling your result too much, and don't dwell too long on the philosophical implications lest you should lose your mind.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 16, 2023, 08:32:12 PM
Thanks! One of the mysteries of physics for me is why does it seem that theoretical work is focused on objects on the 10^-36 m scale or incredibly far away, when it seems like problems such as the Navier-Stokes equations or superconductivity would be more pressing. Of course this could just be an outsider's illusion and more work be ongoing on the latter for all I know.

Liral, do you have all you need to proceed with the code?

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 16, 2023, 09:33:40 PM
The problems of fundamental inanimate physics and cosmology are the most intriguing, exciting, and grand ones in all of science because to solve them is to solve all others by proxy of the more-abstract sciences of chemistry, biology, psychology, and so on, and to know the past, present, and future of the universe.  Yet hence likewise melancholy: to see the world as a great machine working by rules unknown is to see it, with every discovery, more as a mere contraption proceeding to its ultimate fate.  So, while mysteries still remain in the world, while curiosity can still be felt and satisfied, what bright and noble mind would yearn to spend its eyeblink existence developing a higher-temperature superconductor or quieter airplane propeller when it could instead hunt for the theory of everything--or the story of how everything came to be?

...

I believe I have what I need to proceed with the code, though I do agree that moving the target rather than the weapons confuses me.  Thanks for your help, CapnHector. :)

Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 16, 2023, 10:15:10 PM
Haha. This must be a bug in human cognition (and one that is present in myself as well since if you look at my degree planning in math you would think I am allergic to anything practical). For some reason we consider Grothendieck to be the greatest mathematician of the past century when even the average scientist is more likely to say "excuse me" than know what you are talking about if you ever should have reason to bring him up, I think, while Ronald Fisher is probably unknowingly cited by every. single. scientist in all fields except math at some point. Although I'm an amateur so I really wouldn't know, but that's my impression. I would disagree about the ultimate problem in science though. The physical world is accidental, while the Langlands program for example could reveal some of the essence of necessary and ultimate truth, one that is not accidental but necessarily true, unalterable and fundamental. I suppose it depends on whether your definition of science includes mathematics, though.

As for rotating the target, well, I mean, if you move your head it is exactly the same as if everything except your head were to move around, right? The formulation is really really really simple: imagine you are looking ahead (let's move 0 degrees to in front). Positive degrees are to the left and negative to the right. You see a target at 20 degrees that you want to hit with your right fist, but that can only reach to 10 degrees. So you must rotate yourself which is equivalent to rotating the target so that the target is at 10 degrees. The difference is one of language only so if instead of "rotate the target to" you write "rotate our ship so the target is at" then it is the exact same thing but said differently.

Let me know when I can be helpful again. I think one thing is that we do not need the flux management system to test weapons. For them, it is even likely that the appropriate statistic is "time to kill" paired with "flux to kill" rather than the single statistic "time to kill as a function of max flux and dissipation". I was also thinking about another thing. Maybe instead of an AI you could still use arithmetic. Like, suppose we have a set of weapons and priorities. Then, you could do
- fire weapon with priority 1 as many times as possible. Compute how much flux is left over (at each timepoint)
- fire weapon with priority 2 as many times as you can using this leftover flux. Compute how much flux is left over.
- etc. until you have gone through all weapons.

There is a certain downside to this which is that if we ever want to add more dynamic flux (say, return fire) then this won't work as the computation would be recursive.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: intrinsic_parity on January 17, 2023, 12:44:05 AM
I am not saying doing things in the ship-fixed frame is inherently wrong, and I understand how to do it that way, I just don't see why you wouldn't do it in a stationary frame. If you ever want to do stuff like model shots as projectiles with travel time, or consider dynamic motion of ships during combat, using a rotation frame results in stuff like non-accelerating (moving in a straight line) projectiles following a curved path in your coordinate system if your ship is rotating. And it certainly seems like it would be more complicated computationally and unintuitive to have to update the states of every other object in the simulation whenever your own ship moves, rather than just updating the state of your ship.

The way I would do it is just make the ship class have x,y coordinates and an angle/orientation (defined wrt a stationary frame). Then the vector between ships is just the difference in their position coordinates, and the direction of a weapon arc center is just the the angle between it and 'forward' on the ship plus the rotation angle of the ship (and you can get a unit vector from that easily). That's all very simple, and extends naturally to having ships moving around, or having projectiles modeled etc.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 17, 2023, 04:00:57 AM
You'll have no argument from me - if you are doing that, then that is the way to go. In fact when trying to model ships moving about (back on page 7, seems like an eternity ago) I used x and y coordinates myself. Here it just seemed more natural to do it this way for the angle finding algorithm because we are literally only considering angles so I didn't even think about this stuff or about modeling ships moving about more generally, but rather just did the first thing that came to mind like usual MO. Whether we'll include real ship movement is, I believe, an unresolved issue at this time, but yes, do it that way when the time comes.

We have a github now so also if you feel like programming some ship movement related stuff as a separate track to this thing then I believe that is/will be, at least soon, when Liral gets the py-version of the first draft together, possible?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 17, 2023, 01:22:11 PM
I don't know if I can translate this angle code because I'm slowly getting lost in the incremental edits to it.  My code yields a graph, which is flat except three narrow, high peaks at -60, 22, and 100 degrees; the peak at -60 degrees reverses on itself at its middle.  It also prints intermediate the results of minimum and maximum means, bounds, and the optimum angle.  Would you please compare your intermediate results to mine so I can find where my code is going wrong?

Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
from math import pi



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))


def probability_hit_between(
        lower_bound: float,
        upper_bound: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit between two coordinates.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    return (probability_hit_within(upper_bound, standard_deviation,
                                   uniform_distribution_width)
            - probability_hit_within(lower_bound, standard_deviation,
                                     uniform_distribution_width))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / pi


def minimum_and_maximum_means(
        spread: float,
        angle: float,
        arc: float) -> tuple:
    """
    Return the minimum and maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    minimum_mean = angle - (spread + arc) / 2
    maximum_mean = angle + (spread - arc) / 2
    if minimum_mean > 180: minimum_mean -= 360
    elif minimum_mean < -179: minimum_mean += 360
    if maximum_mean > 180: maximum_mean -= 360
    elif maximum_mean < -179: maximum_mean += 360
    return minimum_mean, maximum_mean
   

def transformed_hit_coordinate(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    #weapon arc does not include 180 to  - 179
    if(maximum_mean >= minimum_mean):
        if(angle >= minimum_mean and angle <= maximum_mean): return(angle)
        if(min(abs(angle - maximum_mean),360 - abs(angle - maximum_mean))
           > min(abs(angle - minimum_mean),360 - abs(angle - minimum_mean))):
            return minimum_mean
        return maximum_mean
    #weapon arc includes 180 to  - 179 but does not cross 0
    if(maximum_mean <= 0 and minimum_mean >= 0):
        if(angle < 0 and angle < maximum_mean): return(angle)
        if(angle >= 0 and angle > minimum_mean): return(angle)
        if(angle >= 0 and angle <= minimum_mean):
            if(abs(angle - minimum_mean) >= angle - maximum_mean):
                return maximum_mean
            return minimum_mean
        if(angle < 0 and angle >= maximum_mean):
            if(abs(angle - minimum_mean) >= abs(angle - maximum_mean)):
                return maximum_mean
            return minimum_mean
    #weapon arc includes 0 and 180 to  - 179
    if(angle < 0 and angle <= minimum_mean and angle >= maximum_mean):
        if(abs(angle - maximum_mean) > abs(angle - minimum_mean)):
            return minimum_mean
        return maximum_mean
    if(angle >= 0 and angle <= minimum_mean and angle >= maximum_mean):
        if(angle - maximum_mean > abs(angle - minimum_mean)):
            return minimum_mean
        return maximum_mean
    return angle


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transformed_hit_coordinate(angle, minimum_mean, maximum_mean)


def upper_bounds(width: int, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    width - how many cells across the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * pi * distance
    ship_angle = width / c
    cell_angle = ship_angle / width
    angles = [-ship_angle / 2]
    for i in range(width): angles.append(angles[-1] + cell_angle)
    return tuple(angle * c for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_between(bounds[j-1], bounds[j],
                                            standard_deviation,
                                            spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 3) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]


def distributions(
        weapons: tuple,
        target: tuple,
        distance: float,
        standard_deviation: float):
    """
    Return for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_mean, maximum_mean = minimum_and_maximum_means(
                weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
            minimum_means.append(minimum_mean)
            maximum_means.append(maximum_mean)
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])
    print()
    print("spread, arc, angle")
    for weapon in weapons:
        print(weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
    print()
    print("minimum and maximum means")
    for min_mean, max_mean in zip(minimum_means, maximum_means):
        print(min_mean, max_mean)
    print()
    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-359, 361))
 
    target_angular_size = arc_to_deg(target["width"], distance) / 4
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        weapon_score = 0
        for i, weapon in enumerate(weapons):
            angle = transformed_angle(target_positional_angle,
                                      minimum_means[i],
                                      maximum_means[i])
            probability = probability_hit_between(
                angle - target_angular_size,
                angle + target_angular_size,
                target_positional_angle_error,
                weapon["spread"])
            weapon_score += (WEAPON_SCORES[weapon["size"], weapon["pd"]]
                             * probability)
        weapon_score_at_angles.append(weapon_score)
 
    #now, note that angle -180 is just angle 180, angle -359 is angle
    #1, and so on, so
    #these must be summed with angles -179 to 180
    for i in range(180):
        weapon_score_at_angles[i+360] += weapon_score_at_angles[i]
    #likewise note that angle 360 is just angle 0, angle 359 is angle
    #-1, and so on
    for i in range(540, 720):
        weapon_score_at_angles[i-360] += weapon_score_at_angles[i]
 
    #having summed, select angles -179 to 180
    weapon_score_at_angles = weapon_score_at_angles[181:540]
   
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)
   
    import matplotlib.pyplot as plt
    plt.scatter(x_axis, weapon_score_at_angles)
   
    optimum_angle_index = middle_index_of_approximate_maxima(
        weapon_score_at_angles)
    optimum_angle = x_axis[optimum_angle_index]
    print("Optimum Angle:", optimum_angle)
    print()
    bounds = upper_bounds(len(target.armor_grid.cells[0]), distance)
    print("Bounds")
    print(tuple(round(bound, 3) for bound in bounds))
    print()
    distributions = []
    for i, weapon in enumerate(weapons):
        spread_distance = deg_to_arc(weapon["spread"], distance)
        angle_difference = transformed_angle(optimum_angle,
                                             minimum_means[i],
                                             maximum_means[i])
        adjustment = deg_to_arc(angle_difference, distance)
        adjusted_bounds = tuple(bound + adjustment for bound in bounds)
        distributions.append(hit_distribution(adjusted_bounds,
                                              standard_deviation,
                                              spread_distance))
    return distributions

if __name__ == "__main__":
    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str):
            return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str):
            return self._data[name]

    class TestArmorGrid:
        def __init__(self, width: int):
            self.cells = [[i for i in range(width)]]
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str):
            return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "LARGE", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12)

    for distribution in distributions(test_weapons, test_target, 1000, 50):
        print(tuple(round(element, 3) for element in distribution))
[close]
Result
spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
22.5 27.5
12.5 17.5
100.0 100.0
-70.0 -70.0
-60.0 -60.0
27.5 32.5

Optimum Angle: 22

Bounds
(-6.0, -5.0, -4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)

(0.423, 0.005, 0.005, 0.005, 0.005, 0.005, 0.005, 0.005, 0.005, 0.005, 0.005, 0.005, 0.005, 0.514)
(0.839, 0.004, 0.003, 0.003, 0.003, 0.003, 0.003, 0.003, 0.003, 0.003, 0.003, 0.003, 0.003, 0.122)
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
(0.077, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.003, 0.003, 0.003, 0.003, 0.003, 0.893)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 17, 2023, 08:09:25 PM
Sure. Here are the results using your weapon parameters. The graph has three symmetrical sharp peaks (in shape similar to previous - sorry, mobile, so saving and posting graph is a little tricky. I did get my computer back) in the middle, near +180 and near -179. We select the right peak which is highest.


1] "right phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "pd gun 3:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "photon torpedo:"
 [1] 0.826 0.006 0.006 0.006 0.006 0.006 0.006 0.006 0.006 0.006 0.006 0.006
[13] 0.006 0.104
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser      7    -10           20      5    -17.5     -2.5
2    left phaser      7     10           20      5      2.5     17.5
3       pd gun 1      7   -160           20      0   -170.0   -150.0
4       pd gun 2      7    180           20      0    170.0   -170.0
5       pd gun 3      7    160           20      0    150.0    170.0
6 photon torpedo      7     90          120     90     75.0    105.0


Remember, only the transform hit coord function and the min and max means were changed, and in addition remove the sum operation at the end (-359 to 360 thing). This I think is still present in your code. You translated the logical and I will check it later, but I'd recommend translating the vector (inteinsic_parity's) version of the transform hit coord function since it is simpler to translate I think (less switches)

Here is the full code with the vector version of the calculation.
Spoiler
Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, -10, 20, 5)
weapon2 <- c("left phaser", 7, 10, 20, 5)
weapon3 <- c("pd gun 1", 7, -160, 20, 0)
weapon4 <- c("pd gun 2",7, 180, 20, 0)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 90, 120, 90, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2+weapon[[5]]/2,
      weapon[[3]]+weapon[[4]]/2-weapon[[5]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}

transform_hit_coord <- function(angle, weapon){
  angle_rad <- angle * pi/180
  facing_rad <- weapon$facing * pi / 180
  angle_to_weapon <- acos(sin(angle_rad)*sin(facing_rad)+cos(angle_rad)*cos(facing_rad))*( 180 / pi )
  if(weapon$spread > weapon$tracking_arc) return(weapon$facing)
  if(angle_to_weapon <= (weapon$tracking_arc-weapon$spread)/2) return(angle)
  max_mean_rad <- weapon$max_mean * pi /180
  min_mean_rad <- weapon$min_mean * pi /180
  angle_to_min <- acos(sin(angle_rad)*sin(min_mean_rad)+cos(angle_rad)*cos(min_mean_rad))
  angle_to_max <- acos(sin(angle_rad)*sin(max_mean_rad)+cos(angle_rad)*cos(max_mean_rad))
  if(angle_to_max >= angle_to_min) return(weapon$min_mean)
  return(weapon$max_mean)
}
#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship

sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below -359 or above +360
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth/2
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth/2
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }
 
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                          [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
 
}

#sample runs
main(ship, 1000, 50, weapons[1:6,])
[close]

Remember you can run this at rdrr.io or other websites online yourself just copypasting it there to get any graphs and tests you like.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 17, 2023, 10:43:28 PM
All right Liral, working with programiz.com to run Python online, I was able to fix some but not all bugs. First of all, you were passing incorrect parameters to the minimum and maximum mean function on line 263. Second, you had reversed the + and - operations when adding brackets to the min_max_mean function (adding brackets to try to simplify code is dangerous it seems!). So now it prints a "close but not right" output so there is likely something wrong in the distribution because I was unable to find any errors in the transform_hit_coordinate function. I had also parsed your test weapon incorrectly above, so here are corrected results for the R.

Here is the python, unfortunately I had to remove graphing code to use the online tool:
code
Code
 """
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
from math import pi



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))


def probability_hit_between(
        lower_bound: float,
        upper_bound: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit between two coordinates.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    return (probability_hit_within(upper_bound, standard_deviation,
                                   uniform_distribution_width)
            - probability_hit_within(lower_bound, standard_deviation,
                                     uniform_distribution_width))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / pi


def minimum_and_maximum_means(
        spread: float,
        angle: float,
        arc: float) -> tuple:
    """
    Return the minimum and maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    minimum_mean = angle - (arc - spread) / 2
    maximum_mean = angle + (arc - spread) / 2
    if minimum_mean > 180: minimum_mean -= 360
    elif minimum_mean < -179: minimum_mean += 360
    if maximum_mean > 180: maximum_mean -= 360
    elif maximum_mean < -179: maximum_mean += 360
    return minimum_mean, maximum_mean
   

def transformed_hit_coordinate(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    #weapon arc does not include 180 to  - 179
    if(maximum_mean >= minimum_mean):
        if(angle >= minimum_mean and angle <= maximum_mean): return(angle)
        if(min(abs(angle - maximum_mean),360 - abs(angle - maximum_mean))
           > min(abs(angle - minimum_mean),360 - abs(angle - minimum_mean))):
            return minimum_mean
        return maximum_mean
    #weapon arc includes 180 to  - 179 but does not cross 0
    if(maximum_mean <= 0 and minimum_mean >= 0):
        if(angle < 0 and angle < maximum_mean): return(angle)
        if(angle >= 0 and angle > minimum_mean): return(angle)
        if(angle >= 0 and angle <= minimum_mean):
            if(abs(angle - minimum_mean) >= angle - maximum_mean):
                return maximum_mean
            return minimum_mean
        if(angle < 0 and angle >= maximum_mean):
            if(abs(angle - minimum_mean) >= abs(angle - maximum_mean)):
                return maximum_mean
            return minimum_mean
    #weapon arc includes 0 and 180 to  - 179
    if(angle < 0 and angle <= minimum_mean and angle >= maximum_mean):
        if(abs(angle - maximum_mean) > abs(angle - minimum_mean)):
            return minimum_mean
        return maximum_mean
    if(angle >= 0 and angle <= minimum_mean and angle >= maximum_mean):
        if(angle - maximum_mean > abs(angle - minimum_mean)):
            return minimum_mean
        return maximum_mean
    return angle


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transformed_hit_coordinate(angle, minimum_mean, maximum_mean)


def upper_bounds(width: int, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    width - how many cells across the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * pi * distance
    ship_angle = width / c
    cell_angle = ship_angle / width
    angles = [-ship_angle / 2]
    for i in range(width): angles.append(angles[-1] + cell_angle)
    return tuple(angle * c for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_between(bounds[j-1], bounds[j],
                                            standard_deviation,
                                            spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 3) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]


def distributions(
        weapons: tuple,
        target: tuple,
        distance: float,
        standard_deviation: float):
    """
    Return for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_mean, maximum_mean = minimum_and_maximum_means(
                weapon["spread"], weapon.slot["angle"], weapon.slot["arc"])
            minimum_means.append(minimum_mean)
            maximum_means.append(maximum_mean)
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])
    print()
    print("spread, arc, angle")
    for weapon in weapons:
        print(weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
    print()
    print("minimum and maximum means")
    for min_mean, max_mean in zip(minimum_means, maximum_means):
        print(min_mean, max_mean)
    print()
    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-179, 180))
 
    target_angular_size = arc_to_deg(target["width"], distance) / 4
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        weapon_score = 0
        for i, weapon in enumerate(weapons):
            angle = transformed_angle(target_positional_angle,
                                      minimum_means[i],
                                      maximum_means[i])
            probability = probability_hit_between(
                angle - target_angular_size,
                angle + target_angular_size,
                target_positional_angle_error,
                weapon["spread"])
            weapon_score += (WEAPON_SCORES[weapon["size"], weapon["pd"]]
                             * probability)
        weapon_score_at_angles.append(weapon_score)
 
 
   
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)

    optimum_angle_index = middle_index_of_approximate_maxima(
        weapon_score_at_angles)
    optimum_angle = x_axis[optimum_angle_index]
    print("Optimum Angle:", optimum_angle)
    print()
    bounds = upper_bounds(len(target.armor_grid.cells[0]), distance)
    print("Bounds")
    print(tuple(round(bound, 3) for bound in bounds))
    print()
    distributions = []
    for i, weapon in enumerate(weapons):
        spread_distance = deg_to_arc(weapon["spread"], distance)
        angle_difference = transformed_angle(optimum_angle,
                                             minimum_means[i],
                                             maximum_means[i])
        adjustment = deg_to_arc(angle_difference, distance)
        adjusted_bounds = tuple(bound + adjustment for bound in bounds)
        distributions.append(hit_distribution(adjusted_bounds,
                                              standard_deviation,
                                              spread_distance))
    return distributions

if __name__ == "__main__":
    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str):
            return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str):
            return self._data[name]

    class TestArmorGrid:
        def __init__(self, width: int):
            self.cells = [[i for i in range(width)]]
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str):
            return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "LARGE", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12)

    for distribution in distributions(test_weapons, test_target, 1000, 50):
        print(tuple(round(element, 3) for element in distribution))
[close]

Output

spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0
minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 169
Bounds
(-6.0, -5.0, -4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)
(1.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0)
(1.0, -0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
(0.32, 0.007, 0.007, 0.007, 0.007, 0.007, 0.007, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.591)
(0.452, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.452)
(0.934, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.045)


(correct output:)

[1] "right phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0.005 0.009 0.020 0.039 0.066 0.098 0.126 0.144 0.143 0.124 0.094 0.063
[13] 0.037 0.032
[1] "pd gun 3:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "photon torpedo:"
 [1] 0.518 0.095 0.090 0.081 0.068 0.054 0.039 0.025 0.015 0.008 0.004 0.002
[13] 0.001 0.000
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser      7    -10           20      5    -17.5     -2.5
2    left phaser      7     10           20      5      2.5     17.5
3       pd gun 1      7   -160           20      0   -170.0   -150.0
4       pd gun 2      7    180           20      0    170.0   -170.0
5       pd gun 3      7    160           20      0    150.0    170.0
6 photon torpedo      7    120           90      5     77.5    162.5
[1] 169
(https://i.ibb.co/FJcCgCQ/image.png) (https://imgbb.com/)


Edit: I found another bug (or difference from my code). Your ship width function is incorrect. You must return the pixel bounds for the ship. Here is the correct output:

 [1] -1.100000e+02 -9.166667e+01 -7.333333e+01 -5.500000e+01 -3.666667e+01
 [6] -1.833333e+01  1.634938e-14  1.833333e+01  3.666667e+01  5.500000e+01
[11]  7.333333e+01  9.166667e+01  1.100000e+02


Your output was in degrees. However, if I ask it to convert deg to arc I still get the wrong output:


    return tuple(deg_to_arc(angle * c,distance) for angle in angles)

Output:

Bounds
(-104.72, -87.266, -69.813, -52.36, -34.907, -17.453, 0.0, 17.453, 34.907, 52.36, 69.813, 87.266, 104.72)


If I give it the correct information manually by writing

def upper_bounds(width: int, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    width - how many cells across the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * pi * distance
    ship_angle = 0.03501409
    cell_angle = 0.002917841
    angles = [-ship_angle / 2]
    for i in range(width): angles.append(angles[-1] + cell_angle)
    return tuple(angle * c for angle in angles)


Then it returns the correct output:

Bounds(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)


But there is still something quite wrong with the complete output, though PD gun #3 is correct now:


spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 167

Bounds(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)

(1.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0)
(1.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
(0.001, 0.001, 0.004, 0.01, 0.022, 0.041, 0.069, 0.101, 0.129, 0.144, 0.142, 0.122, 0.091, 0.124)
(0.014, 0.019, 0.038, 0.064, 0.096, 0.125, 0.143, 0.143, 0.125, 0.096, 0.064, 0.038, 0.019, 0.014)
(0.338, 0.093, 0.096, 0.095, 0.09, 0.08, 0.067, 0.052, 0.037, 0.024, 0.014, 0.007, 0.004, 0.002)


Anyway based on experience I feel like I should point out that it seems like the elegance gained from putting in brackets and small substitutions like c = 2*pi*distance may not be worth the time it may take to fix bugs if any occur, but this is of course personal preference.

Anyway, it seems like there is a problem somewhere in the optimum angle selection, because if in addition to the correct ship parameters I also give it the correct angle manually

    optimum_angle = 169


then the output is right.

spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 169

Bounds
(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)

(1.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
(0.005, 0.009, 0.02, 0.039, 0.066, 0.098, 0.126, 0.144, 0.143, 0.124, 0.094, 0.063, 0.037, 0.032)
(0.014, 0.019, 0.038, 0.064, 0.096, 0.125, 0.143, 0.143, 0.125, 0.096, 0.064, 0.038, 0.019, 0.014)
(0.518, 0.095, 0.09, 0.081, 0.068, 0.054, 0.039, 0.025, 0.015, 0.008, 0.004, 0.002, 0.001, 0.0)
>


All right found one problem in the upper bounds function:

You wrote

def upper_bounds(width: int, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    width - how many cells across the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * pi * distance
    ship_angle = width / (c)
    cell_angle = ship_angle / width
    angles = [-ship_angle / 2]
    for i in range(width): angles.append(angles[-1] + cell_angle)
    return tuple(angle * c for angle in angles)


when the correct is divide by number of cells rather than width. However, this does not fix the code so there must be something else too.

Anyway so all in all it seems like the distribution and angle functions are in fact correct because it prints the correct output when manually fed the correct ship parameters and correct optimum angle, so it seems like the angle selection function (maybe the sum function?) has an error and the ship upper bounds function should probably be rewritten as it must have more than one error and is a relatively short one. Unfortunately I was unable to completely resolve these issues at this time.

Here is my final testing code:
code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
from math import pi



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))


def probability_hit_between(
        lower_bound: float,
        upper_bound: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit between two coordinates.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    return (probability_hit_within(upper_bound, standard_deviation,
                                   uniform_distribution_width)
            - probability_hit_within(lower_bound, standard_deviation,
                                     uniform_distribution_width))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / pi


def minimum_and_maximum_means(
        spread: float,
        angle: float,
        arc: float) -> tuple:
    """
    Return the minimum and maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    minimum_mean = angle - (arc - spread) / 2
    maximum_mean = angle + (arc - spread) / 2
    if minimum_mean > 180: minimum_mean -= 360
    elif minimum_mean < -179: minimum_mean += 360
    if maximum_mean > 180: maximum_mean -= 360
    elif maximum_mean < -179: maximum_mean += 360
    return minimum_mean, maximum_mean
   

def transformed_hit_coordinate(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    #weapon arc does not include 180 to  - 179
    if(maximum_mean >= minimum_mean):
        if(angle >= minimum_mean and angle <= maximum_mean): return(angle)
        if(min(abs(angle - maximum_mean),360 - abs(angle - maximum_mean))
           > min(abs(angle - minimum_mean),360 - abs(angle - minimum_mean))):
            return minimum_mean
        return maximum_mean
    #weapon arc includes 180 to  - 179 but does not cross 0
    if(maximum_mean <= 0 and minimum_mean >= 0):
        if(angle < 0 and angle < maximum_mean): return(angle)
        if(angle >= 0 and angle > minimum_mean): return(angle)
        if(angle >= 0 and angle <= minimum_mean):
            if(abs(angle - minimum_mean) >= angle - maximum_mean):
                return maximum_mean
            return minimum_mean
        if(angle < 0 and angle >= maximum_mean):
            if(abs(angle - minimum_mean) >= abs(angle - maximum_mean)):
                return maximum_mean
            return minimum_mean
    #weapon arc includes 0 and 180 to  - 179
    if(angle < 0 and angle <= minimum_mean and angle >= maximum_mean):
        if(abs(angle - maximum_mean) > abs(angle - minimum_mean)):
            return minimum_mean
        return maximum_mean
    if(angle >= 0 and angle <= minimum_mean and angle >= maximum_mean):
        if(angle - maximum_mean > abs(angle - minimum_mean)):
            return minimum_mean
        return maximum_mean
    return angle


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transformed_hit_coordinate(angle, minimum_mean, maximum_mean)


def upper_bounds(width: int, distance: float) -> tuple:
    """
    Return the upper bounds of this ship at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    width - how many cells across the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * pi * distance
    ship_angle = 0.03501409
    cell_angle = 0.002917841
    angles = [-ship_angle / 2]
    for i in range(width): angles.append(angles[-1] + cell_angle)
    return tuple(angle * c for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_between(bounds[j-1], bounds[j],
                                            standard_deviation,
                                            spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 5) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]


def distributions(
        weapons: tuple,
        target: tuple,
        distance: float,
        standard_deviation: float):
    """
    Return for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_mean, maximum_mean = minimum_and_maximum_means(
                weapon["spread"], weapon.slot["angle"], weapon.slot["arc"])
            minimum_means.append(minimum_mean)
            maximum_means.append(maximum_mean)
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])
    print()
    print("spread, arc, angle")
    for weapon in weapons:
        print(weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
    print()
    print("minimum and maximum means")
    for min_mean, max_mean in zip(minimum_means, maximum_means):
        print(min_mean, max_mean)
    print()
    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-179, 180))
 
    target_angular_size = arc_to_deg(target["width"], distance) / 2
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        weapon_score = 0
        for i, weapon in enumerate(weapons):
            angle = transformed_angle(target_positional_angle,
                                      minimum_means[i],
                                      maximum_means[i])
            probability = probability_hit_between(
                angle - target_angular_size,
                angle + target_angular_size,
                target_positional_angle_error,
                weapon["spread"])
            weapon_score += (WEAPON_SCORES[weapon["size"], weapon["pd"]]
                             * probability)
        weapon_score_at_angles.append(weapon_score)
 
 
   
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)

    optimum_angle_index = middle_index_of_approximate_maxima(
        weapon_score_at_angles)
    optimum_angle = x_axis[optimum_angle_index]
    print("Optimum Angle:", optimum_angle)
    print()
    bounds = upper_bounds(len(target.armor_grid.cells[0]), distance)
    print("Bounds")
    print(tuple(round(bound, 3) for bound in bounds))
    print()
    distributions = []
    for i, weapon in enumerate(weapons):
        spread_distance = deg_to_arc(weapon["spread"], distance)
        angle_difference = transformed_angle(optimum_angle,
                                             minimum_means[i],
                                             maximum_means[i])
        adjustment = deg_to_arc(angle_difference, distance)
        adjusted_bounds = tuple(bound + adjustment for bound in bounds)
        distributions.append(hit_distribution(adjusted_bounds,
                                              standard_deviation,
                                              spread_distance))
    return distributions

if __name__ == "__main__":
    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str):
            return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str):
            return self._data[name]

    class TestArmorGrid:
        def __init__(self, width: int):
            self.cells = [[i for i in range(width)]]
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str):
            return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "LARGE", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12)

    for distribution in distributions(test_weapons, test_target, 1000, 50):
        print(tuple(round(element, 3) for element in distribution))
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 18, 2023, 04:54:10 AM
I've fixed the upper_bounds function and replaced probability_hit_between calls with explicit differences of probability_hit_within calls.  If I set optimum_angle to 169, I get your result.  Also, the name of the upper_bounds function confuses me because I don't know what it's supposed to return.  It calculates angle bounds but then multiples them all by 2 * pi * distance right at the end, so I think those are bounds along the arc.

Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
import math



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * math.pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / math.pi
   
   
def minimum_mean(spread: float, angle: float, arc: float):
    """
    Return the minimum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    minimum_mean = angle - (arc - spread) / 2
    if minimum_mean > 180: minimum_mean -= 360
    elif minimum_mean < -179: minimum_mean += 360
    return minimum_mean
   
   
def maximum_mean(spread: float, angle: float, arc: float):
    """
    Return the maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    maximum_mean = angle + (arc - spread) / 2
    if maximum_mean > 180: maximum_mean -= 360
    elif maximum_mean < -179: maximum_mean += 360
    return maximum_mean
   

def transformed_hit_coordinate(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    #weapon arc does not include 180 to  - 179
    if maximum_mean >= minimum_mean:
        if minimum_mean <= angle <= maximum_mean: return angle
        a, b = abs(angle - maximum_mean), abs(angle - minimum_mean)
        return (minimum_mean if min(a, 360 - a) > min(b, 360 - b) else
                maximum_mean)
    #weapon arc includes 180 to  - 179 but does not cross 0
    if maximum_mean <= 0 <= minimum_mean:
        if angle < 0:
            if angle < maximum_mean: return angle
            if abs(angle - minimum_mean) >= abs(angle - maximum_mean):
                return maximum_mean
            return minimum_mean
        if angle > minimum_mean: return angle
        if abs(angle - minimum_mean) >= angle - maximum_mean:
            return maximum_mean
        return minimum_mean
       
    #weapon arc includes 0 and 180 to  - 179
    if maximum_mean <= angle <= minimum_mean:
        if angle < 0:
            if abs(angle - maximum_mean) > abs(angle - minimum_mean):
                return minimum_mean
            return maximum_mean
        return (minimum_mean if angle - maximum_mean > abs(angle - minimum_mean)
                else maximum_mean)
    return angle


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transformed_hit_coordinate(angle, minimum_mean, maximum_mean)


def angle_bounds(
        cells_across: int,
        pixels_across: float,
        distance: float) -> tuple:
    """
    Return the angle of the upper bound of each cell of this armor
    grid at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    cells_across - how many cells wide the armor grid of the ship is
    pixels_across - how many pixels wide the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * math.pi * distance
    ship_angle = pixels_across / c
    cell_angle = ship_angle / cells_across
    angles = [-ship_angle / 2]
    for i in range(cells_across): angles.append(angles[-1] + cell_angle)
    return tuple(angle * c for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
       
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread_distance)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_within(bounds[j],
                                           standard_deviation,
                                           spread_distance)
                    - probability_hit_within(bounds[j-1],
                                             standard_deviation,
                                             spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 5) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]


def distributions(
        weapons: tuple,
        target: tuple,
        distance: float,
        standard_deviation: float) -> tuple:
    """
    Return for each weapon the probability of to hit each of this
    target's cells, as well as of a miss due to hitting below ship's
    lowest bound in the first cell or above ship's highest bound in the
    last one.
   
    target - tuple of information about the ship being shot at
    distance - range to target
    standard deviation - of target position
    weapons - tuple of weapons
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_means.append(minimum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
            maximum_means.append(maximum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])
    print()
    print("spread, arc, angle")
    for weapon in weapons:
        print(weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
    print()
    print("minimum and maximum means")
    for min_mean, max_mean in zip(minimum_means, maximum_means):
        print(min_mean, max_mean)
    print()
    #now, for angles -359 to 360 (all possible signed angles, calculate dps)
    target_positional_angles = tuple(i for i in range(-179, 180))
 
    target_angular_size = arc_to_deg(target["width"], distance) / 2
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        weapon_score = 0
        for i, weapon in enumerate(weapons):
            angle = transformed_angle(target_positional_angle,
                                      minimum_means[i],
                                      maximum_means[i])
            upper_angle = angle + target_angular_size
            lower_angle = angle - target_angular_size
            probability = (probability_hit_within(upper_angle,
                                                  standard_deviation,
                                                  weapon["spread"])
                           - probability_hit_within(lower_angle,
                                                    standard_deviation,
                                                    weapon["spread"]))
            weapon_score += (WEAPON_SCORES[weapon["size"], weapon["pd"]]
                             * probability)
        weapon_score_at_angles.append(weapon_score)
 
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)

    optimum_angle_index = middle_index_of_approximate_maxima(
        weapon_score_at_angles)
    optimum_angle = 169#x_axis[optimum_angle_index]
    print("Optimum Angle:", optimum_angle)
    print()
    bounds = upper_bounds(len(target.armor_grid.cells[0]), target["width"],
                          distance)
    print("Bounds")
    print(tuple(round(bound, 3) for bound in bounds))
    print()
    print("Distributions")
    distributions = []
    for i, weapon in enumerate(weapons):
        spread_distance = deg_to_arc(weapon["spread"], distance)
        angle_difference = transformed_angle(optimum_angle,
                                             minimum_means[i],
                                             maximum_means[i])
        adjustment = deg_to_arc(angle_difference, distance)
        adjusted_bounds = tuple(bound + adjustment for bound in bounds)
        distribution = hit_distribution(adjusted_bounds,
                                        standard_deviation,
                                        spread_distance)
        print(tuple(round(probability, 3) for probability in distribution))
        distributions.append(distribution)
    return distributions

if __name__ == "__main__":
    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str):
            return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str):
            return self._data[name]

    class TestArmorGrid:
        def __init__(self, cells_across: int):
            self.cells = [[i for i in range(cells_across)]]
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str):
            return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "LARGE", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12)

    distributions(test_weapons, test_target, 1000, 50)
       
[close]
Result
spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 169

Bounds
(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)

Distributions
(1.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
(0.005, 0.009, 0.02, 0.039, 0.066, 0.098, 0.126, 0.144, 0.143, 0.124, 0.094, 0.063, 0.037, 0.032)
(0.014, 0.019, 0.038, 0.064, 0.096, 0.125, 0.143, 0.143, 0.125, 0.096, 0.064, 0.038, 0.019, 0.014)
(0.518, 0.095, 0.09, 0.081, 0.068, 0.054, 0.039, 0.025, 0.015, 0.008, 0.004, 0.002, 0.001, 0.0)
[/spoiler=Result]
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 18, 2023, 05:01:15 AM
Well, uh, apologies for my terrible function naming practices. Anyway let's say a ship is like this:

(-infinity,a)(a,b)(b,c)(c,d)(d,+infinity)

where those are coordinates along the x axis and corresponding intervals that represent cells. The first and last cell represent misses. The upper bounds function is supposed to return (a,b,c,d) (that is, all finite upper bounds of intervals) and then we calculate hit probabilities as: probability to miss below = probability coordinate less than a. Probability to hit cell 1 = probability coordinate less than b - probability coordinate less than a. etc. except for the last cell it is probability to miss above = 1 - probability coordinate less than d. Anyway that is why it is called upper bounds. Thinking about it I suppose you could just as well call it lower bounds, since picking the left bound of each interval where finite would give the same result. I thought about upper bounds specifically because we calculate the probability using a "less than" I guess.

This must be very close because you are getting the correct result when the optimum angle is right, so it's just a matter of figuring out why the optimum angle becomes wrong. it is not wrong - see below.

I found another thing: in this latest code on lines 292 and 295 it read standard_deviation, but it should read target_positional_angle_error (because that is in degrees, standard_deviation is in pixels)

Even after that, there is definitely something weird going on here. Here are your results for weapon score at indices 340:350


    print(weapon_score_at_angles[340:350])


[14.166934372136467, 14.891770867986182, 15.759365378507708, 16.55467822599273, 17.158811464253947, 17.498336565310417, 17.54829121987713, 17.33444568907221, 16.913947930559214, 16.347135239482807]
Optimum Angle: 167


Here are mine:

[341]  9.031166e+00  9.203635e+00  9.493293e+00  9.830988e+00  1.022583e+01
[346]  1.066577e+01  1.108756e+01  1.138932e+01  1.146283e+01  1.123129e+01


Now let's actually compute manually what is the correct weapon score at angle 169: it is, according to our distributions,

(1-(0.005+0.032))*7+(1-(0.014+0.014))*7+(1-(0.518))*7=16.919

And at weapon angle 167, which your code recommends:
(1-(0.125))*7+(1-(0.014+0.014))*7+(1-(0.340))*7=17.549


So, in fact, your code is right, not mine, here, and it's pointless to keep looking for more answers since that is the answer to the final missing degrees problem. It looks like my code has some issue here instead.

Because the manual calculations agree with your code's score I would declare it complete at this point. I have been going over it too and was unable to find any errors, that's why I turned to the possibility that the original code is wrong. Sorry about wasting your time with this stuff, although the end result is good because bugs were fixed and the code is double checked.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 18, 2023, 12:05:34 PM
Well, uh, apologies for my terrible function naming practices. Anyway let's say a ship is like this:

(-infinity,a)(a,b)(b,c)(c,d)(d,+infinity)

where those are coordinates along the x axis and corresponding intervals that represent cells. The first and last cell represent misses. The upper bounds function is supposed to return (a,b,c,d) (that is, all finite upper bounds of intervals) and then we calculate hit probabilities as: probability to miss below = probability coordinate less than a. Probability to hit cell 1 = probability coordinate less than b - probability coordinate less than a. etc. except for the last cell it is probability to miss above = 1 - probability coordinate less than d. Anyway that is why it is called upper bounds. Thinking about it I suppose you could just as well call it lower bounds, since picking the left bound of each interval where finite would give the same result. I thought about upper bounds specifically because we calculate the probability using a "less than" I guess.

Thanks for the explanation because I understand better now.  So, if the function returns the pixel distance across each cell, and if we have a formula for cell size, then why have we used this function to calculate it rather than sum cell sizes directly?
Code: Direct Sum
def bounds(cells_across: int, cell_size: float) -> tuple: return tuple(cell_size * i for i in range(cells_across + 1))

>> bounds(2, 5)
>> (0, 2, 4, 6, 8, 10, 12)
Quote
This must be very close because you are getting the correct result when the optimum angle is right, so it's just a matter of figuring out why the optimum angle becomes wrong. it is not wrong - see below.

I found another thing: in this latest code on lines 292 and 295 it read standard_deviation, but it should read target_positional_angle_error (because that is in degrees, standard_deviation is in pixels)

Even after that, there is definitely something weird going on here. Here are your results for weapon score at indices 340:350


    print(weapon_score_at_angles[340:350])


[14.166934372136467, 14.891770867986182, 15.759365378507708, 16.55467822599273, 17.158811464253947, 17.498336565310417, 17.54829121987713, 17.33444568907221, 16.913947930559214, 16.347135239482807]
Optimum Angle: 167


Here are mine:

[341]  9.031166e+00  9.203635e+00  9.493293e+00  9.830988e+00  1.022583e+01
[346]  1.066577e+01  1.108756e+01  1.138932e+01  1.146283e+01  1.123129e+01


Now let's actually compute manually what is the correct weapon score at angle 169: it is, according to our distributions,

(1-(0.005+0.032))*7+(1-(0.014+0.014))*7+(1-(0.518))*7=16.919

And at weapon angle 167, which your code recommends:
(1-(0.125))*7+(1-(0.014+0.014))*7+(1-(0.340))*7=17.549


So, in fact, your code is right, not mine, here, and it's pointless to keep looking for more answers since that is the answer to the final missing degrees problem. It looks like my code has some issue here instead.

Because the manual calculations agree with your code's score I would declare it complete at this point. I have been going over it too and was unable to find any errors, that's why I turned to the possibility that the original code is wrong. Sorry about wasting your time with this stuff, although the end result is good because bugs were fixed and the code is double checked.

Woah, I wasn't expecting that!  So, here's the latest version of the code and its results.
Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
import math



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * math.pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / math.pi
   
   
def minimum_mean(spread: float, angle: float, arc: float):
    """
    Return the minimum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    minimum_mean = angle - (arc - spread) / 2
    if minimum_mean > 180: minimum_mean -= 360
    elif minimum_mean < -179: minimum_mean += 360
    return minimum_mean
   
   
def maximum_mean(spread: float, angle: float, arc: float):
    """
    Return the maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    maximum_mean = angle + (arc - spread) / 2
    if maximum_mean > 180: maximum_mean -= 360
    elif maximum_mean < -179: maximum_mean += 360
    return maximum_mean
   
   
def minimum_and_maximum_means(weapons: tuple) -> tuple:
    """
    Reurn the minimum and maximum means of a tuple of Weapons.
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_means.append(minimum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
            maximum_means.append(maximum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])
    return minimum_means, maximum_means
   

def transformed_hit_coordinate(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    #weapon arc does not include 180 to  - 179
    if maximum_mean >= minimum_mean:
        if minimum_mean <= angle <= maximum_mean: return angle
        a, b = abs(angle - maximum_mean), abs(angle - minimum_mean)
        return (minimum_mean if min(a, 360 - a) > min(b, 360 - b) else
                maximum_mean)
    #weapon arc includes 180 to  - 179 but does not cross 0
    if maximum_mean <= 0 <= minimum_mean:
        if angle < 0:
            if angle < maximum_mean: return angle
            if abs(angle - minimum_mean) >= abs(angle - maximum_mean):
                return maximum_mean
            return minimum_mean
        if angle > minimum_mean: return angle
        if abs(angle - minimum_mean) >= angle - maximum_mean:
            return maximum_mean
        return minimum_mean
       
    #weapon arc includes 0 and 180 to  - 179
    if maximum_mean <= angle <= minimum_mean:
        if angle < 0:
            if abs(angle - maximum_mean) > abs(angle - minimum_mean):
                return minimum_mean
            return maximum_mean
        return (minimum_mean if angle - maximum_mean > abs(angle - minimum_mean)
                else maximum_mean)
    return angle


def transformed_angle(
        angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    weapon - a tuple ending with min_mean and max_mean
    angle - angle of the target relative to our ship
    """
    return angle - transformed_hit_coordinate(angle, minimum_mean, maximum_mean)


def upper_bounds(
        cells_across: int,
        pixels_across: float,
        distance: float) -> tuple:
    """
    Return the angle of the upper bound of each cell of this armor
    grid at this distance.
   
    The bounds are a tuple with the lower edge of the ship in index 0
    and upper bounds of all its armor cells at successive indices.
   
    cells_across - how many cells wide the armor grid of the ship is
    pixels_across - how many pixels wide the armor grid of the ship is
    distance - range to the ship
    """
    c = 2 * math.pi * distance
    ship_angle = pixels_across / c
    cell_angle = ship_angle / cells_across
    angles = [-ship_angle / 2]
    for i in range(cells_across): angles.append(angles[-1] + cell_angle)
    return tuple(angle * c for angle in angles)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
       
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread_distance)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_within(bounds[j],
                                           standard_deviation,
                                           spread_distance)
                    - probability_hit_within(bounds[j-1],
                                             standard_deviation,
                                             spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def probability_hit_at_angle(
        weapon_angle: float,
        spread_angle: float,
        target_angular_size: float,
        target_positional_angle: float,
        target_positional_angle_error: float) -> float:
    """
    Return the probability for a weapon of this spread and facing this
    direction to hit a target of this angular size positioned at this angle
    with this standard deviation thereof.
   
    weapon_angle - direction the weapon is pointing (-179, 180)
    spread_angle - uniform angular dispersion of weapon shot facing
    target_angular_size - size of the target in degrees
    target_positional_angle - location of the target (-179, 180)
    target_positional_angle_error - standard deviation of
                                    target_positional_angle
    """
    upper_bound = weapon_angle + target_angular_size
    lower_bound = weapon_angle - target_angular_size
    return (probability_hit_within(upper_bound, target_positional_angle_error,
                                   spread_angle)
            - probability_hit_within(lower_bound, target_positional_angle_error,
                                     spread_angle))
                         
                                           
def total_probable_weapon_score_at_angles(
        weapons: tuple,
        minimum_means: tuple,
        maximum_means: tuple,
        target_width: float,
        distance: float,
        standard_deviation: float) -> tuple:                                       
    #now, for angles -359 to 360 (all possible signed angles)
    target_positional_angles = tuple(i for i in range(-179, 180))
    target_angular_size = arc_to_deg(target_width, distance) / 2
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_scores = tuple(WEAPON_SCORES[weapon["size"], weapon["pd"]]
                          for weapon in weapons)
    total_probable_weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        total_probable_weapon_score = 0
        for i, weapon in enumerate(weapons):
            weapon_angle = transformed_angle(target_positional_angle,
                                             minimum_means[i], maximum_means[i])
            probability = probability_hit_at_angle(
                weapon_angle, weapon["spread"], target_angular_size,
                target_positional_angle, target_positional_angle_error)
            total_probable_weapon_score += probability * weapon_scores[i]
        total_probable_weapon_score_at_angles.append(
            total_probable_weapon_score)
    return total_probable_weapon_score_at_angles


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 5) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]
   
   
def optimum_angle(
        weapons: tuple,
        minimum_means: tuple,
        maximum_means: tuple,
        target_width: float,
        distance: float,
        standard_deviation: float) -> float:
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)
    optimum_angle_index = middle_index_of_approximate_maxima(
        total_probable_weapon_score_at_angles(weapons, minimum_means,
        maximum_means, target_width, distance, standard_deviation))
    return x_axis[optimum_angle_index]
   
   
def distribution(
        spread: float,
        minimum_mean: float,
        maximum_mean: float,
        angle: float,
        bounds: tuple,
        distance: float,
        standard_deviation: float) -> tuple:
    """
    Return the probability of a weapon of this spread to hit between each
    pair of the bounds of an armor row positioned at this angle and
    distance
   
    as well as of a miss due to hitting below ship's lowest bound in the
    first cell or above ship's highest bound in the last one.
   
    spread - of weapons
    minimum_means - minimum_mean of this weapon's probable score at this
                    angle
    maximum_means - maximum_mean of this weapon's probable score at this
                    angle
    distance - range to target
    standard deviation - of target position
    """
    spread_distance = deg_to_arc(weapon["spread"], distance)
    angle_difference = transformed_angle(angle, minimum_mean, maximum_mean)
    adjustment = deg_to_arc(angle_difference, distance)
    adjusted_bounds = tuple(bound + adjustment for bound in bounds)
    distribution = hit_distribution(adjusted_bounds, standard_deviation,
                                    spread_distance)
    return distribution
   

if __name__ == "__main__":
    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str):
            return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str):
            return self._data[name]

    class TestArmorGrid:
        def __init__(self, cells_across: int):
            self.cells = [[i for i in range(cells_across)]]
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str):
            return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "LARGE", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12)
    standard_deviation = 50
    distance = 1000
    print("spread, arc, angle")
    for weapon in test_weapons:
        print(weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
    print()
    minimum_means, maximum_means = minimum_and_maximum_means(test_weapons)
    print("minimum and maximum means")
    for min_mean, max_mean in zip(minimum_means, maximum_means):
        print(min_mean, max_mean)
    print()
    optimum_angle = optimum_angle(test_weapons, minimum_means, maximum_means,
                                  test_target["width"], distance,
                                  standard_deviation)
    print("Optimum Angle:", optimum_angle)
    print()
    bounds = upper_bounds(len(test_target.armor_grid.cells[0]),
                              test_target["width"], distance)
    print("Bounds")
    print(tuple(round(bound, 3) for bound in bounds))
    print()
    distributions = tuple(distribution(weapon["spread"], minimum_means[i],
                                       maximum_means[i], optimum_angle, bounds,
                                       distance, standard_deviation)
                         for i, weapon in enumerate(test_weapons))
    print("Distributions")
    for distribution in distributions:
        print(tuple(round(probability, 3) for probability in distribution))
[close]
Result
spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 167

Bounds
(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)

Distributions
(1.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0, -0.0)
(0.008, 0.01, 0.018, 0.029, 0.043, 0.059, 0.073, 0.085, 0.092, 0.096, 0.095, 0.091, 0.082, 0.219)
(0.061, 0.041, 0.056, 0.071, 0.083, 0.092, 0.096, 0.096, 0.092, 0.083, 0.071, 0.056, 0.041, 0.061)
(0.338, 0.093, 0.096, 0.095, 0.09, 0.08, 0.067, 0.052, 0.037, 0.024, 0.014, 0.007, 0.004, 0.002)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 18, 2023, 07:21:45 PM
Oh, probably because a) I tried to put everything in a function like you said and b) spaghetti code in the actual function (maybe it came from when I was going to write it using.degrees rather than px) but there is no real reason to do it using angles rather than pixels. Do it however you think is best, it's a very simple calculation.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 18, 2023, 09:41:38 PM
I was able to find and fix the problem in my original code. It was one of those dumb, simple mistakes that seem to be able to hide in plain sight forever, until you look at the output carefully.

I had written

  shipwidth <- arc_to_deg(ship[5], range)/2

and

    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth / 2
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth / 2


As a result, the ship width got divided by 2 two times, so we were using one-quarter ship width where it should have been one-half. When this is fixed (remove one of the /2:s) then the output is


[1] "right phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0.001 0.001 0.004 0.010 0.022 0.041 0.069 0.101 0.129 0.144 0.142 0.122
[13] 0.091 0.124
[1] "pd gun 3:"
 [1] 0.014 0.019 0.038 0.064 0.096 0.125 0.143 0.143 0.125 0.096 0.064 0.038
[13] 0.019 0.014
[1] "photon torpedo:"
 [1] 0.338 0.093 0.096 0.095 0.090 0.080 0.067 0.052 0.037 0.024 0.014 0.007
[13] 0.004 0.002
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser      7    -10           20      5    -17.5     -2.5
2    left phaser      7     10           20      5      2.5     17.5
3       pd gun 1      7   -160           20      0   -170.0   -150.0
4       pd gun 2      7    180           20      0    170.0   -170.0
5       pd gun 3      7    160           20      0    150.0    170.0
6 photon torpedo      7    120           90      5     77.5    162.5
[1] optimum angle: 167

(https://i.ibb.co/6NwSKbZ/image.png) (https://imgbb.com/)

And for the weapon score at angles we get

[341]  1.416693e+01  1.489177e+01  1.575937e+01  1.655468e+01  1.715881e+01
[346]  1.749834e+01  1.754829e+01  1.733445e+01  1.691395e+01  1.634714e+01


That is, we now get the exact same output. So I would call this section finished. Nice!

code
Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, -10, 20, 5)
weapon2 <- c("left phaser", 7, 10, 20, 5)
weapon3 <- c("pd gun 1", 7, -160, 20, 0)
weapon4 <- c("pd gun 2",7, 180, 20, 0)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 120, 90, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2+weapon[[5]]/2,
      weapon[[3]]+weapon[[4]]/2-weapon[[5]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}

transform_hit_coord <- function(angle, weapon){
  angle_rad <- angle * pi/180
  facing_rad <- weapon$facing * pi / 180
  angle_to_weapon <- acos(sin(angle_rad)*sin(facing_rad)+cos(angle_rad)*cos(facing_rad))*( 180 / pi )
  if(weapon$spread > weapon$tracking_arc) return(weapon$facing)
  if(angle_to_weapon <= (weapon$tracking_arc-weapon$spread)/2) return(angle)
  max_mean_rad <- weapon$max_mean * pi /180
  min_mean_rad <- weapon$min_mean * pi /180
  angle_to_min <- acos(sin(angle_rad)*sin(min_mean_rad)+cos(angle_rad)*cos(min_mean_rad))
  angle_to_max <- acos(sin(angle_rad)*sin(max_mean_rad)+cos(angle_rad)*cos(max_mean_rad))
  if(angle_to_max >= angle_to_min) return(weapon$min_mean)
  return(weapon$max_mean)
}
#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship

sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below -359 or above +360
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }
 
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                          [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
  print(optimum_angle)
print(dps_at_angles)
}

#sample runs
main(ship, 1000, 50, weapons[1:6,])
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 19, 2023, 02:49:49 AM
Fantastic!  I'm glad you found the root cause of the error.  Now that we agree on the output for these inputs, we need a test that compares the output of this code to these correct answers and, if necessary, tests other inputs to ensure the code works for the general case.  What inputs might we need besides the ones we've tested?
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 19, 2023, 03:54:58 AM
It's a tricky one if you want to make sure this is everywhere correct, because there is of course no way to compute every possible distribution manually. That shouldn't be necessary either, though, since this code is humanly understandable and if it is correct it is correct. If you want you can first try the set of all the random weapons alongside the normal ones. The correct output for that is (I've added the weapon score at degrees as the final part of the output)


[1] "right phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "pd gun 3:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 1
[1] "photon torpedo:"
 [1] 0.046 0.035 0.049 0.064 0.078 0.088 0.094 0.096 0.094 0.088 0.077 0.064
[13] 0.048 0.079
[1] "bb gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "space marine teleporter:"
 [1] 0.391 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018 0.018
[13] 0.018 0.391
[1] "turbolaser:"
 [1] 0.165 0.051 0.054 0.056 0.058 0.058 0.058 0.058 0.058 0.058 0.056 0.054
[13] 0.051 0.165
[1] "hex bolter:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "singularity projector:"
 [1] 0.384 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021 0.021
[13] 0.021 0.364
[1] "subspace resonance kazoo:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left nullspace projector:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "telepathic embarrassment generator:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "perfectoid resonance torpedo:"
 [1] 0.462 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031 0.031
[13] 0.030 0.168
[1] "entropy inverter gun:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "mini-collapsar rifle:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "false vacuum tunneler:"
 [1] 0.342 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026 0.026
[13] 0.026 0.342
                                 name damage facing tracking_arc spread
1                        right phaser      7    -10           20      5
2                         left phaser      7     10           20      5
3                            pd gun 1      7   -160           20      0
4                            pd gun 2      7    180           20      0
5                            pd gun 3      7    160           20      0
6                      photon torpedo      7    120           90      5
7                              bb gun      5   -150           11     20
8             space marine teleporter     78     69          173     29
9                          turbolaser     92    122          111      9
10                         hex bolter     24   -136           38     20
11              singularity projector     95     28          122     25
12           subspace resonance kazoo     68   -139           12      2
13           left nullspace projector     10     28           54      0
14 telepathic embarrassment generator     30    -31           35      8
15       perfectoid resonance torpedo     34     72           10     17
16               entropy inverter gun     78    -60           13     24
17               mini-collapsar rifle     27     28           16     13
18              false vacuum tunneler     32     78          157     20
   min_mean max_mean
1     -17.5     -2.5
2       2.5     17.5
3    -170.0   -150.0
4     170.0   -170.0
5     150.0    170.0
6      77.5    162.5
7    -150.0   -150.0
8      -3.0    141.0
9      71.0    173.0
10   -145.0   -127.0
11    -20.5     76.5
12   -144.0   -134.0
13      1.0     55.0
14    -44.5    -17.5
15     72.0     72.0
16    -60.0    -60.0
17     26.5     29.5
18      9.5    146.5
[1] optimum angle: 77
  [1]   8.05296159   8.80936083   9.75062973  10.79857489  11.85096854
  [6]  12.81922615  13.65814361  14.37104027  14.98992342  15.54467961
 [11]  16.03857849  16.44121778  16.70130719  16.77400454  16.65078057
 [16]  16.37587872  16.03765223  15.73719315  15.55274478  15.52375760
 [21]  15.66828262  16.02933960  16.73096388  18.01681178  20.24233271
 [26]  23.79890253  28.97314198  35.78562737  43.88729840  52.58557040
 [31]  60.94555674  68.08855516  73.45890747  76.84821685  78.33843279
 [36]  78.14325535  77.17493255  76.28345570  75.55417820  75.02159565
 [41]  74.66976280  74.45174482  74.31374762  74.21202373  74.11895633
 [46]  74.02127299  73.17732760  70.77688626  66.66781195  60.77988165
 [51]  53.29920462  44.75322831  35.94305686  27.74068220  20.84531841
 [56]  15.61434025  12.03622458   9.83140319   8.60813802   7.99566620
 [61]   7.71399671   7.58346127   7.50072518   7.40804323   7.27079097
 [66]   7.06660114   6.78278891   6.41689371   5.97632754   5.47564166
 [71]   4.93229804   4.36291444   3.78152145   3.20012848   2.63074501
 [76]   2.08740226   1.58672148   1.14618210   0.78041367   0.49714189
 [81]   0.29503168   0.16501050   0.09507160   0.07708525   0.11350153
 [86]   0.22181992   0.43485622   0.79538163   1.34542594   2.11337555
 [91]   3.10418956   4.29736017   5.65337928   7.12493410   8.66701458
 [96]  10.24162059  11.81622660  13.35830712  14.82986219  16.18588267
[101]  17.37906009  18.36990426  19.13797309  19.68843768  20.05028664
[106]  20.26705119  20.38477868  20.44251364  20.46800546  20.47814813
[111]  20.48192284  20.48377058  20.48645713  20.49357771  20.51236015
[116]  20.55722452  20.65364001  20.84048026  21.16823828  21.69141739
[121]  22.45661007  23.49155487  24.80160974  26.37653254  28.20382220
[126]  30.27981732  32.61033321  35.19884125  38.02788066  41.04340002
[131]  44.14974377  47.21695872  50.09632676  52.63795191  54.70622200
[136]  56.24723934  57.51247553  58.50184592  59.18736524  59.57513381
[141]  59.70421955  59.63674029  59.44327891  59.19015739  58.93391587
[146]  58.72407202  58.61014795  58.64594306  58.88511135  59.36727672
[151]  60.10070784  61.05169576  62.14866845  63.30111245  64.42461657
[156]  65.46003898  66.37918806  67.17791641  67.86393013  68.44713897
[161]  68.93609283  69.33910462  69.63379497  69.64052408  69.36069182
[166]  68.84799780  68.17459053  67.43017454  66.72059900  66.16312126
[171]  65.87350051  65.94193056  66.40298601  67.21475746  68.26384493
[176]  69.39976143  70.48230738  71.39379110  71.95902694  72.15338694
[181]  71.98673991  71.55553345  70.98368623  70.43413651  69.97464060
[186]  69.65643608  69.52676948  69.61803177  69.93937110  70.47460475
[191]  71.18748631  72.03166793  72.96060463  73.93338698  74.91526046
[196]  75.87452883  76.77919356  77.57457014  78.10246925  78.33649446
[201]  78.29940741  78.03676505  77.60749122  77.07330747  76.49077664
[206]  75.90714106  75.36063204  74.88939474  74.51037812  74.22506193
[211]  74.00971068  73.83555532  73.66289324  73.44589171  73.14073879
[216]  72.71389312  72.14860742  71.44737806  70.62913071  69.72246780
[221]  68.75838417  67.76597853  66.77291090  65.80987907  64.91631069
[226]  64.14342729  63.55150252  63.20112718  63.14287336  63.41292382
[231]  64.04034062  65.06397275  66.54796100  68.58168695  71.25722757
[236]  74.53486961  78.28718599  82.37887205  86.62209579  90.85396744
[241]  94.99829203  99.07726221 103.16730149 107.32587687 111.53068503
[246] 115.66108808 119.52508326 122.91170594 125.64010781 127.58390838
[251] 128.66709744 129.30179417 129.91122320 130.46140637 130.91607833
[256] 131.23843094 131.39498169 131.38137561 131.30892808 131.17553449
[261] 130.95285790 130.61410860 130.14144105 129.53164474 128.79718906
[266] 127.96199598 127.05402848 126.09792346 125.10992285 124.09548566
[271] 123.04885934 121.95415087 120.78827714 119.52628636 118.14836834
[276] 116.64623226 115.02601699 113.30637215 111.51296946 109.67260044
[281] 107.80997167 105.94863331 104.11531894 102.34519151 100.68448190
[286]  99.18731465  97.90579653  96.87630685  96.10813681  95.58040865
[291]  95.24900899  95.05987286  94.96221917  94.91677030  94.89775800
[296]  94.89062561  94.88823046  94.88751152  94.88731886  94.88727282
[301]  94.88726301  94.88726115  94.88726084  94.88726079  94.88726078
[306]  94.88726078  94.88726080  94.88726093  94.88726179  94.88726688
[311]  94.88729342  94.88741626  94.88792009  94.88975153  94.89565200
[316]  94.91250062  94.95514229  95.05079606  95.24097950  95.57613816
[321]  96.09966477  96.82450127  97.71401795  98.68156404  99.61427042
[326] 100.41070902 101.01171114 101.40815891 101.62480725 101.69251316
[331] 101.69223393 101.69133302 101.68876611 101.68232517 101.66814727
[336] 101.64091198 101.59556107 101.53058949 101.45079264 101.36508285
[341] 101.27575359 101.16156349 100.94329854 100.40401700  99.44375326
[346]  98.02651563  96.17712768  93.97613792  91.53504758  88.96616145
[351]  86.36037640  83.77905249  81.26006030  78.37804436  74.72792062
[356]  70.40704128  65.56577243  60.38875933  55.05553227  49.70296410


(https://i.ibb.co/VSQY3qV/image.png) (https://imgbb.com/)

Then, here is a systematic test of the logic wrt. wraparounds:

weapon1 <- c("right phaser", 7, 0, 360, 0)
weapon2 <- c("left phaser", -7, 0, 350, 0)
weapon3 <- c("pd gun 1", 7, 0, 200, 0)
weapon4 <- c("pd gun 2", -7, 0, 190, 0)


Turn the SD off for this test. What is supposed to happen here: note that weapons 1 and 2 cancel each other out everywhere, except from 180...175 and -179...-175 degrees. There will be a spike of 7 where only weapon 1 is firing. There should be two additional spikes of 7 between 95...100 and -95...-100.


[1] "right phaser:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 0 0 0 0 0 0 1 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 0 0 1 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0
          name damage facing tracking_arc spread min_mean max_mean
1 right phaser      7      0          360      0      180      180
2  left phaser     -7      0          350      0     -175      175
3     pd gun 1      7      0          200      0     -100      100
4     pd gun 2     -7      0          190      0      -95       95
[1] 105
  [1] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
 [26] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
 [51] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7  0  0
 [76]  0  0  0 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[101] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[126] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[151] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[176] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[201] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[226] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[251] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[276] -7 -7 -7 -7 -7 -7  0  0  0  0  0 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[301] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[326] -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7 -7
[351] -7 -7 -7  0  0  0  0  0  0  0


We can tell we have found one more bug: the code thinks weapon 1 is fixed, because its max mean is identical to min mean, even though its tracking arc is 360!

We fix this by writing this to the start of the transform_hit_coord function:
  if(weapon$tracking_arc==360) return(angle)


This, however, does not fix the issue by itself. Now we get this:


[1] "right phaser:"
 [1] 0 0 0 0 0 0 1 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 0 0 0 0 0 0 1 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 0 0 0 0 0 0 0 0 1 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0
          name damage facing tracking_arc spread min_mean max_mean
1 right phaser      7      0          360      0      180      180
2  left phaser     -7      0          350      0     -175      175
3     pd gun 1      7      0          200      0     -100      100
4     pd gun 2     -7      0          190      0      -95       95
[1] -102
  [1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 [38] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 7
 [75] 7 7 7 7 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[112] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[149] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[186] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[223] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[260] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 7 7 7 7 7 0 0 0 0 0 0 0 0 0 0
[297] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[334] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

(https://i.ibb.co/Zxvvv4Y/image.png) (https://imgbb.com/)

Why? Well, actually I have no idea. Geometry! *shakes fist at Euclid* I am starting to feel like I don't want to hear about angles for a while after this.

Edit: I wrote a long post here thinking about vectors and having to rewrite the code, and I even did rewrite it using vectors, but it didn't make a difference, but then I finally figured it out and it is not actually wrong what you see there. Here is the reason: these are weapons of spread 0. The ship's width is 12.6 degrees. We only consider whether we can hit target ship for weapon score, not how well we hit it. There is no effect at the edge of the ship BECAUSE the weapon worth +7 and -7 can both track it there! When the ship is at 180 degrees then it can still be hit from +175 degrees and we do not have a spread for this weapon so no consideration is made for how well it is hit.

So it is actually correct this way. And further, we can now with this knowledge assume that, if we give all the weapons a spread of 13 degrees then the boundaries at the edges will appear.


weapon1 <- c("right phaser", 7, 0, 360, 0)
weapon2 <- c("left phaser", -7, 0, 350, 0)
weapon3 <- c("pd gun 1", 7, 0, 200, 0)
weapon4 <- c("pd gun 2", -7, 0, 190, 0)


And so they do.

[1] "right phaser:"
 [1] 0.258 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.258
[1] "left phaser:"
 [1] 0.258 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.040 0.258
[1] "pd gun 1:"
 [1] 0.005 0.005 0.009 0.014 0.019 0.025 0.030 0.034 0.037 0.039 0.040 0.040 0.040 0.661
[1] "pd gun 2:"
 [1] 0.000 0.000 0.000 0.001 0.002 0.003 0.006 0.010 0.015 0.021 0.027 0.032 0.035 0.849
          name damage facing tracking_arc spread min_mean max_mean
1 right phaser      7      0          360     13   -173.5    173.5
2  left phaser     -7      0          350     13   -168.5    168.5
3     pd gun 1      7      0          200     13    -93.5     93.5
4     pd gun 2     -7      0          190     13    -88.5     88.5


(https://i.ibb.co/bXjTxvF/image.png) (https://ibb.co/Yh4m6dX)
Dang that was much ado about nothing.

fixed code
Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, 0, 360, 13)
weapon2 <- c("left phaser", -7, 0, 350, 13)
weapon3 <- c("pd gun 1", 7, 0, 200, 13)
weapon4 <- c("pd gun 2", -7, 0, 190, 13)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 120, 90, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2+weapon[[5]]/2,
      weapon[[3]]+weapon[[4]]/2-weapon[[5]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}

transform_hit_coord <- function(angle, weapon){
  #weapon arc does not include 180 to -179
  if(weapon$tracking_arc==360) return(angle)
  if(weapon$max_mean >= weapon$min_mean) {
    if(angle >= weapon$min_mean & angle <= weapon$max_mean) return(angle)
    if(min(abs(angle-weapon$max_mean),360-abs(angle-weapon$max_mean)) > min(abs(angle-weapon$min_mean),360-abs(angle-weapon$min_mean))) return(weapon$min_mean)
    return(weapon$max_mean)
  }
  #weapon arc includes 180 to -179 but does not cross 0
  if(weapon$max_mean <= 0 & weapon$min_mean >= 0){
    if(angle < 0 & angle < weapon$max_mean) return(angle)
    if(angle >= 0 & angle > weapon$min_mean) return(angle)
    if(angle >= 0 & angle <= weapon$min_mean) {
      if(abs(angle-weapon$min_mean) >= angle-weapon$max_mean) return(weapon$max_mean)
      return(weapon$min_mean)
    }
    if(angle < 0 & angle >= weapon$max_mean) {
      if(abs(angle-weapon$min_mean) >= abs(angle-weapon$max_mean)) return(weapon$max_mean)
      return(weapon$min_mean)
    }
  }
  #weapon arc includes 0 and 180 to -179
  if(angle < 0 & angle <= weapon$min_mean & angle >= weapon$max_mean){
    if(abs(angle-weapon$max_mean) > abs(angle-weapon$min_mean)) return(weapon$min_mean)
    return(weapon$max_mean)
  }
  if(angle >= 0 & angle <= weapon$min_mean & angle >= weapon$max_mean){
    if(angle-weapon$max_mean > abs(angle-weapon$min_mean)) return(weapon$min_mean)
    return(weapon$max_mean)
  }
  return(angle)
}
#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship

sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    #as a sanity check, furthermore, angle cannot be below -359 or above +360
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }
 
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                          [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
  print(optimum_angle)
  print(dps_at_angles)
}

#sample runs
main(ship, 1000, 50, weapons[1:4,])
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 19, 2023, 09:57:50 AM
So... should I change anything about the code, and what are the test results that I should expect for a broad range of weapons?  :o
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 19, 2023, 10:12:34 AM
Well, I'll try to fix one more thing which is the asymmetry you see in the output full of -7s. That is, I'm not sure a weapon fixed at 180 deg with 0 spread is hitting a wide ship at -179 correctly. For now you should add the

if(weapon$tracking_arc==360) return(angle)

clause since that is one thing we genuinely missed.

I am honestly not sure what you mean by what kind of test results you should expect for a broad range of weapons? A broad range, I suppose. Specify a bit what you have in mind? Remember what we have here is a machine to print quite complex graphs based on an arbitrary set of numbers, then find the maximum of that graph and then output a probability distribution based on that, so there are in fact literally infinite possible outputs.

Edit: having thought it through the correct way to go here is likely the vector math since this fixes the ship wraparound without any further exceptions and is conceptually simpler and generally less vulnerable to errors. So this requires changing only one function, like so


transform_hit_coord <- function(angle, weapon){
  if(weapon$tracking_arc==360) return(angle)
  if(weapon$spread > weapon$tracking_arc) return(weapon$facing)
  angle_rad <- angle * pi/180
  facing_rad <- weapon$facing * pi / 180
  angle_to_weapon <- acos(sin(angle_rad)*sin(facing_rad)+cos(angle_rad)*cos(facing_rad))*( 180 / pi )
  if(angle_to_weapon <= (weapon$tracking_arc-weapon$spread)/2) return(angle)
  max_mean_rad <- weapon$max_mean * pi /180
  min_mean_rad <- weapon$min_mean * pi /180
  angle_to_min <- acos(sin(angle_rad)*sin(min_mean_rad)+cos(angle_rad)*cos(min_mean_rad))
  angle_to_max <- acos(sin(angle_rad)*sin(max_mean_rad)+cos(angle_rad)*cos(max_mean_rad))
  if(angle_to_max >= angle_to_min) return(weapon$min_mean)
  return(weapon$max_mean)
}


That is to explain: there is an issue in the previous code that we are not wrapping the ship around at the edge correctly (a gun at 180 that cannot aim at all should still hit a 12.6 angle wide ship that is situated at -179 degrees). This could be fixed with one more special case about wrapping around but that is not necessary with vectors, and the vector math is also simpler and therefore more robust so I recommend just switching this function to it. By vectors, I mean we are using the formula
(https://i.ibb.co/9HrJBDK/image.png) (https://ibb.co/ZzM3vj4)

Here is the step by step explanation:


#Find the angle the distribution mean will take when the weapon tracks the target
transform_hit_coord <- function(angle, weapon){
#if the weapon can track 360 degrees it can always align itself with target, so return target's angle
  if(weapon$tracking_arc==360) return(angle)
#if the weapon has more recoil than tracking ability it cannot track even if slotted in a turret with a tracking arc
  if(weapon$spread > weapon$tracking_arc) return(weapon$facing)
  angle_rad <- angle * pi/180
  facing_rad <- weapon$facing * pi / 180
#else, calculate the angle from weapon to target
  angle_to_weapon <- acos(sin(angle_rad)*sin(facing_rad)+cos(angle_rad)*cos(facing_rad))*( 180 / pi )
#if we are within target arc, the distribution can be aligned: return target angle
  if(angle_to_weapon <= (weapon$tracking_arc-weapon$spread)/2) return(angle)
  max_mean_rad <- weapon$max_mean * pi /180
  min_mean_rad <- weapon$min_mean * pi /180
#else, calculate the angle to the minimum and maximum means
  angle_to_min <- acos(sin(angle_rad)*sin(min_mean_rad)+cos(angle_rad)*cos(min_mean_rad))
  angle_to_max <- acos(sin(angle_rad)*sin(max_mean_rad)+cos(angle_rad)*cos(max_mean_rad))
#if we are closer to minimum return minimum
  if(angle_to_max >= angle_to_min) return(weapon$min_mean)
#if none of the above apply, return maximum
  return(weapon$max_mean)
}


Full code:
Spoiler
Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. We will test against a ship formatted in the normal format
ship <- c(14000, 500, 10000, 1500, 220, 12, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, 0, 360, 0)
weapon2 <- c("left phaser", -7, 0, 350, 0)
weapon3 <- c("pd gun 1", 7, 0, 200, 0)
weapon4 <- c("pd gun 2", -7, 0, 190, 0)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 90, 120, 90, 5)

#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
  if(weapon[5] < weapon[4]){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2+weapon[[5]]/2,
      weapon[[3]]+weapon[[4]]/2-weapon[[5]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
  } else {
    return(c(
      weapon[3], weapon[3]
    ))
  }
}

#Output the angle between two vectors angled a and b using a dot b = |a||b|cos(angle) and noting these are
#unit vectors



#Given an angle and a weapon, return the mean of the distribution when the weapon tries to track the target
transform_hit_coord <- function(angle, weapon){
  if(weapon$tracking_arc==360) return(angle)
  if(weapon$spread > weapon$tracking_arc) return(weapon$facing)
 
  angle_rad <- angle * pi/180
  facing_rad <- weapon$facing * pi / 180
  angle_to_weapon <- acos(sin(angle_rad)*sin(facing_rad)+cos(angle_rad)*cos(facing_rad))*( 180 / pi )
  if(angle_to_weapon <= (weapon$tracking_arc-weapon$spread)/2) return(angle)
  max_mean_rad <- weapon$max_mean * pi /180
  min_mean_rad <- weapon$min_mean * pi /180
  angle_to_min <- acos(sin(angle_rad)*sin(min_mean_rad)+cos(angle_rad)*cos(min_mean_rad))
  angle_to_max <- acos(sin(angle_rad)*sin(max_mean_rad)+cos(angle_rad)*cos(max_mean_rad))
  if(angle_to_max >= angle_to_min) return(weapon$min_mean)
  return(weapon$max_mean)
}
#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(ship, range){
  ship_angle <- ship[5]/(2* pi *range)
  cell_angle <- ship_angle/ship[6]
  angles <- vector(mode="double", length = ship[6]+1)
  angles[1] <- -ship_angle/2
  for (i in 1:(length(angles)-1)) angles[i+1] <- angles[i]+cell_angle
  return(angles * 2 * pi * range)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship



sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth/2
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth/2
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }
 
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                          [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship, range)
 
  #calculate and report the distributions for weapons, round for human readability
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)
  print(dps_at_angles)
}

#sample runs
main(ship, 1000, 0, weapons[1:4,])
[close]

This should seriously be the final update, we are already down to minuscule bugs.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 19, 2023, 11:11:09 AM
Ok, where should I add this line?

Code
if(weapon$tracking_arc==360) return(angle)

Sorry, by a broad range I meant the long list of weapons you added plus any others you might like to add because they would test edge cases.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 19, 2023, 11:14:05 AM
I posted a recommendation to re-writing the transform hit coord function above, that also shows where that line should go. I think with this we are done with the testing of edge cases, can't think of more! I'll add that I ran the previous two test with it too; results matched. Sorry about writing this one more time though, it's been quite a laborious thing this angle thingy.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 19, 2023, 12:45:08 PM
Oh, there we go!  Sorry I missed it.  Here's the relevant Python code before adding your change:

Code
def transformed_hit_coordinate(
        target_positional_angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the mean of the distribution of the probability of a weapon
    to hit a target positioned at an angle.
   
    target_positional_angle - angle from the direction the shooter faces
                              to the direction from the shooter to the
                              target
    minimum_mean - minimum mean hit probability of this weapon
    maximum_mean - maximum mean hit probability of this weapon
    """
    #weapon arc does not include 180 to  - 179
    if maximum_mean >= minimum_mean:
        if minimum_mean <= angle <= maximum_mean:
            return target_positional_angle
        a = abs(target_positional_angle - maximum_mean)
        b = abs(target_positional_angle - minimum_mean)
        return (minimum_mean if min(a, 360 - a) > min(b, 360 - b) else
                maximum_mean)
    #weapon arc includes 180 to  - 179 but does not cross 0
    if maximum_mean <= 0 <= minimum_mean:
        if angle < 0:
            if angle < maximum_mean: return angle
            if (abs(target_positional_angle - minimum_mean)
                >= abs(target_positional_angle - maximum_mean)):
                return maximum_mean
            return minimum_mean
        if target_positional_angle > minimum_mean:
            return target_positional_angle
        if (abs(target_positional_angle - minimum_mean)
            >= target_positional_angle - maximum_mean):
            return maximum_mean
        return minimum_mean
       
    #weapon arc includes 0 and 180 to  - 179
    if maximum_mean <= angle <= minimum_mean:
        if target_positional_angle < 0:
            if (abs(target_positional_angle - maximum_mean)
                > abs(target_positional_angle - minimum_mean)):
                return minimum_mean
            return maximum_mean
        return (minimum_mean if target_positional_angle - maximum_mean
                                > abs(target_positional_angle - minimum_mean)
                else maximum_mean)
    return target_positional_angle


def transformed_angle(
        target_positional_angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle, relative to the direction the shooter faces, pointing to
    the mean of the distribution of the probability of a weapon to hit a target
    positioned at an angle relative to the direction the shooter faces.
   
    target_positional_angle - angle from the direction the shooter faces to
                              to the direction from the shooter target to the
                              target
    minimum_mean - minimum mean hit probability of this weapon
    maximum_mean - maximum mean hit probability of this weapon
    """
    return target_positional_angle - transformed_hit_coordinate(
        target_positional_angle, minimum_mean, maximum_mean)

Your change is that, if the weapon has a 360 degree tracking arc, then transformed_hit_coordinate should return the target_positional_angle.  If so, then transformed_angle should return target_positional_angle - target_positional_angle = 0, so we could instead type

Code
angle_difference = (0 if weapon.slot["arc"] == 360 else transformed_angle(
    target_positional_angle, minimum_mean, maximum_mean)

Because transforming the angle would be unnecessary for a weapon of 360 degree tracking, right?  Also, I wish we could remember the purpose of each of these two functions should we ever review or change them.  I suspect it has something to do with tracking arcs, though not strongly enough to have included it in the documentation, so would you please remind me?

Glad the test results match, too!

Thanks for patiently cooperating with my quest for edge cases because every one we catch now is one fewer to hear about, and hope to fix, from a user's bug report later.  8)

Ok, just to be sure, here's my code and the result:

Code
Code
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
import math



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * math.pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / math.pi
   
   
def minimum_mean(spread: float, angle: float, arc: float):
    """
    Return the minimum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    minimum_mean = angle - (arc - spread) / 2
    if minimum_mean > 180: minimum_mean -= 360
    elif minimum_mean < -179: minimum_mean += 360
    return minimum_mean
   
   
def maximum_mean(spread: float, angle: float, arc: float):
    """
    Return the maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    maximum_mean = angle + (arc - spread) / 2
    if maximum_mean > 180: maximum_mean -= 360
    elif maximum_mean < -179: maximum_mean += 360
    return maximum_mean
   
   
def minimum_and_maximum_means(weapons: tuple) -> tuple:
    """
    Reurn the minimum and maximum means of a tuple of Weapons.
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_means.append(minimum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
            maximum_means.append(maximum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])
    return minimum_means, maximum_means
   

def transformed_hit_coordinate(
        target_positional_angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the mean of the distribution of the probability of a weapon
    to hit a target positioned at an angle.
   
    target_positional_angle - angle from the direction the shooter faces
                              to the direction from the shooter to the
                              target
    minimum_mean - minimum mean hit probability of this weapon
    maximum_mean - maximum mean hit probability of this weapon
    """
    #weapon arc does not include 180 to  - 179
    if maximum_mean >= minimum_mean:
        if minimum_mean <= target_positional_angle <= maximum_mean:
            return target_positional_angle
        a = abs(target_positional_angle - maximum_mean)
        b = abs(target_positional_angle - minimum_mean)
        return (minimum_mean if min(a, 360 - a) > min(b, 360 - b) else
                maximum_mean)
    #weapon arc includes 180 to  - 179 but does not cross 0
    if maximum_mean <= 0 <= minimum_mean:
        if target_positional_angle < 0:
            if target_positional_angle < maximum_mean:
                return target_positional_angle
            if (abs(target_positional_angle - minimum_mean)
                >= abs(target_positional_angle - maximum_mean)):
                return maximum_mean
            return minimum_mean
        if target_positional_angle > minimum_mean:
            return target_positional_angle
        if (abs(target_positional_angle - minimum_mean)
            >= target_positional_angle - maximum_mean):
            return maximum_mean
        return minimum_mean
       
    #weapon arc includes 0 and 180 to  - 179
    if maximum_mean <= target_positional_angle <= minimum_mean:
        if target_positional_angle < 0:
            if (abs(target_positional_angle - maximum_mean)
                > abs(target_positional_angle - minimum_mean)):
                return minimum_mean
            return maximum_mean
        return (minimum_mean if target_positional_angle - maximum_mean
                                > abs(target_positional_angle - minimum_mean)
                else maximum_mean)
    return target_positional_angle


def transformed_angle(
        target_positional_angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    target_positional_angle - angle from the direction the shooter faces to
                              to the direction from the shooter target to the
                              target
    minimum_mean - minimum mean hit probability of this weapon
    maximum_mean - maximum mean hit probability of this weapon
    """
    return target_positional_angle - transformed_hit_coordinate(
        target_positional_angle, minimum_mean, maximum_mean)


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
       
        return 0, + tuple(1 if bounds[j] >= 0 and bounds[j-1] < 0 else 0
                          for j in range(len(bounds)))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return ((min(1, max(0, (bounds[0] + spread_distance)) / a),)
                + tuple(
                    (min(1, max(0, (bounds[j] + spread_distance)) / a)
                    - min(1, max(0, (bounds[j-1] + spread_distance)) / a))
                  for j in range(1, len(bounds)))
                + ((1 - min(1, max(0, (bounds[-1] + spread_distance)) / a)),))
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return ((cdf(bounds[0]),)
                + tuple(cdf(bounds[j]) - cdf(bounds[j-1]) for j in
                        range(1, len(bounds)))
                + ((1 - cdf(bounds[-1])),))
    return ((probability_hit_within(bounds[0], standard_deviation,
                                    spread_distance),)
            + tuple(probability_hit_within(bounds[j],
                                           standard_deviation,
                                           spread_distance)
                    - probability_hit_within(bounds[j-1],
                                             standard_deviation,
                                             spread_distance)
                    for j in range(1, len(bounds)))
            + ((1 - probability_hit_within(bounds[-1], standard_deviation,
                                           spread_distance)),))


def probability_hit_at_angle(
        weapon_angle: float,
        spread_angle: float,
        target_angular_size: float,
        target_positional_angle: float,
        target_positional_angle_error: float) -> float:
    """
    Return the probability for a weapon of this spread and facing this
    direction to hit a target of this angular size positioned at this angle
    with this standard deviation thereof.
   
    weapon_angle - direction the weapon is pointing (-179, 180)
    spread_angle - uniform angular dispersion of weapon shot facing
    target_angular_size - size of the target in degrees
    target_positional_angle - location of the target (-179, 180)
    target_positional_angle_error - standard deviation of
                                    target_positional_angle
    """
    upper_bound = weapon_angle + target_angular_size
    lower_bound = weapon_angle - target_angular_size
    return (probability_hit_within(upper_bound, target_positional_angle_error,
                                   spread_angle)
            - probability_hit_within(lower_bound, target_positional_angle_error,
                                     spread_angle))
                         
                                           
def total_probable_weapon_score_at_angles(
        weapons: tuple,
        minimum_means: tuple,
        maximum_means: tuple,
        target_width: float,
        distance: float,
        standard_deviation: float) -> tuple:                                       
    #now, for angles -359 to 360 (all possible signed angles)
    target_positional_angles = tuple(i for i in range(-179, 180))
    target_angular_size = arc_to_deg(target_width, distance) / 2
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_scores = tuple(WEAPON_SCORES[weapon["size"], weapon["pd"]]
                          for weapon in weapons)
    total_probable_weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        total_probable_weapon_score = 0
        for i, weapon in enumerate(weapons):
            weapon_angle = (0 if weapon.slot["arc"] == 360
                            else transformed_angle(target_positional_angle,
                                              minimum_means[i],
                                              maximum_means[i]))
            probability = probability_hit_at_angle(weapon_angle,
                weapon["spread"], target_angular_size, target_positional_angle,
                target_positional_angle_error)
            total_probable_weapon_score += probability * weapon_scores[i]
        total_probable_weapon_score_at_angles.append(
            total_probable_weapon_score)
    return total_probable_weapon_score_at_angles


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 5) for element in row)
    indicies_of_approximate_maxima = tuple(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row))
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]
   
   
def optimum_angle(
        weapons: tuple,
        minimum_means: tuple,
        maximum_means: tuple,
        target_width: float,
        distance: float,
        standard_deviation: float) -> float:
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)
    optimum_angle_index = middle_index_of_approximate_maxima(
        total_probable_weapon_score_at_angles(weapons, minimum_means,
        maximum_means, target_width, distance, standard_deviation))
    return x_axis[optimum_angle_index]
   
   
def distribution(
        spread: float,
        minimum_mean: float,
        maximum_mean: float,
        target_positional_angle: float,
        bounds: tuple,
        distance: float,
        standard_deviation: float) -> tuple:
    """
    Return the probability of a weapon of this spread to hit between each
    pair of the bounds of an armor row positioned at this angle and
    distance
   
    as well as of a miss due to hitting below ship's lowest bound in the
    first cell or above ship's highest bound in the last one.
   
    spread - of the weapon
    minimum_mean - minimum_mean of this weapon's probable score at this
                    angle
    maximum_mean - maximum_mean of this weapon's probable score at this
                    angle
    bounds - of the armor grid cells of the target
    distance - range to target
    standard deviation - of target position
    """
    spread_distance = deg_to_arc(weapon["spread"], distance)
    angle_difference = (0 if weapon.slot["arc"] == 360 else transformed_angle(
        target_positional_angle, minimum_mean, maximum_mean))
    adjustment = deg_to_arc(angle_difference, distance)
    adjusted_bounds = tuple(bound + adjustment for bound in bounds)
    distribution = hit_distribution(adjusted_bounds, standard_deviation,
                                    spread_distance)
    return distribution
   

if __name__ == "__main__":
    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str): return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str): return self._data[name]

    class TestArmorGrid:
        def __init__(self, cells_across: int, cell_size):
            self.cells = [[i for i in range(cells_across)]]
            self.bounds = tuple(i * cell_size for i in range(cells_across))
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str): return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "LARGE", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12, 10)
    standard_deviation = 50
    distance = 1000
    print("spread, arc, angle")
    for weapon in test_weapons:
        print(weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
    print()
    minimum_means, maximum_means = minimum_and_maximum_means(test_weapons)
    print("minimum and maximum means")
    for min_mean, max_mean in zip(minimum_means, maximum_means):
        print(min_mean, max_mean)
    print()
    optimum_angle = optimum_angle(test_weapons, minimum_means, maximum_means,
                                  test_target["width"], distance,
                                  standard_deviation)
    print("Optimum Angle:", optimum_angle)
    print()
    bounds = test_target.armor_grid.bounds
    print("Bounds")
    print(tuple(round(bound, 3) for bound in bounds))
    print()
    distributions = tuple(distribution(weapon["spread"], minimum_means[i],
                                       maximum_means[i], optimum_angle,
                                       test_target.armor_grid.bounds, distance,
                                       standard_deviation)
                         for i, weapon in enumerate(test_weapons))
    print("Distributions")
    for distribution in distributions:
        print(tuple(round(probability, 3) for probability in distribution))
[close]
Result
spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 167

Bounds
(0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110)

Distributions
(1.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0)
(0.241, 0.045, 0.048, 0.05, 0.052, 0.052, 0.053, 0.052, 0.051, 0.049, 0.046, 0.043, 0.219)
(0.5, 0.053, 0.052, 0.05, 0.048, 0.046, 0.042, 0.038, 0.034, 0.03, 0.025, 0.021, 0.061)
(0.859, 0.03, 0.026, 0.021, 0.017, 0.013, 0.01, 0.007, 0.005, 0.004, 0.002, 0.002, 0.002)
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 19, 2023, 07:50:15 PM
Here is what the functions do: transform hit coord - finds the mean of the shot distribution when the gun tracks the target
transformed angle - finds the signed angle from this mean to the weapon

So here might be more reasonable names for them:
transform hit coord -> mean when tracking
transformed angle -> angle from mean

Looks like the change broke something in your code because the result is no longer the same unfortunately. Also it occurs to me that there is another super nice exception to the exception: if somebody has specified a spread of 360 then a gun in a 360 arc mount still can't track. Or it might? Here is where I actually don't know what happens though: in the previous we have assumed the gun can never point outside its arc, leading to that a gun with spread > arc can't track (the recoil is more likely to kick it in the other direction if it tracks, re-aligning the mean with facing*). But if we have a gun with 360 arc and 360 recoil, and the target is to one side, can the recoil spin the gun around a full circle? If it can then no change should be made, the gun can in fact track, although it is irrelevant because shots are still completely random. If it cannot then the gun cannot track (recoil is more likely to kick it to the other side) and the exception that if spread>= tracking angle return facing should be moved above the step that if tracking angle ==360 return target angle. This change is not really necessary though unless we are using the mean angle for something, since shots are fully random with 360 spread.

*This follows from the additional assumption that the recoil/spread means "select a permissible angle for the shot from a uniform distribution with width equal to spread", to be clear. A physical force wouldn't work this way but would instead still permit tracking and instead stress the mount when it hits an impermissible angle.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 19, 2023, 08:33:40 PM
Here is what the functions do: transform hit coord - finds the mean of the shot distribution when the gun tracks the target
transformed angle - finds the signed angle from this mean to the weapon

I could use some more explanation about mean_when_tracking because I'm not sure what "finds the mean of the shot distribution when the gun tracks the target" means.

What is the difference between the results?

I wonder how this new exception would apply to, say, a gun with a 5 degree tracking arc and 15 degree spread, too.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 19, 2023, 09:22:45 PM
Sorry, typo.

transformed angle - finds the signed angle from this mean to the weapon


Should read

transformed angle - finds the signed angle from this mean to the target

My bad

Anyway, so it all goes back to what we think about spread. I chose to assume spread is constant (else guns would be more accurate near the edge of their arc). Additionally I chose to assume guns can't go beyond their arc (else we could have "pseudo-tracking" beyond the maximum arc due to recoil repeatedly kicking the gun past its maximum angle).

Taken together these assumptions inevitably mean that the mean of the angle distribution of shots can't reach the edge of the gun's arc. Why? If it could go closer than spread/2, but the gun can't go over its arc, the sprrad would narrow at the edge.

Note that this is talking about the mean. If the mean cannot go closer than spread/2 then that means that with 0 sd individual shots can only go to the arc's edge and no further, exactly and if it were otherwise they could go beyond the arc.

So getting back to your gun with 5 arc and 15 spread: say that the arc is from 0 to 5. This touches on the special case that the spread is greater than the arc. In this case we must let go of one of the assumptions above. I relaxed the assumption that shots don't go beyond the arc (and kept that spread is constant). Now we have a choice of what to do. Should the gun pseudo-track in the opposite direction? Let's say target is at +10. In that case we would get a mean angle for the dist of -5. This seems unreasonable. So I kept what vestige we can of not going beyond the arc and say that this gun cannot track at all (its dist mean is entirely determined by spread but stays within the arc). So we get a mean of 2.5 and individual shots can go from +17.5 to -12.5.

Note that we could make different assumptions and end with different results. For example if we permit pseudo-tracking beyond the arc (shots can generally go arbitrarily far beyond it even with SD=0) then the dist mean can align with arc edge. Or, in the special case, if we held on to shots not going beyond the arc, then the spread would shrink to 5 deg for this gun in this mount instead.

I suppose that while means are hard to test in the game, the special case could be tested empirically. Mod in a gun with say a 180 deg spread. Put it in a turret mount with a 5 degree arc. Will its spread be 5 or 180? This would tell us what is the realistic thing to do here, since the above is based on reasoning only. This experiment could also rule in or out pseudo-tracking: give the gun a rapid rate of fire. Will the gun fire successive shots to one side (pseudo-tracking, the dist mean can go beyond the arc) or will they be randomly distributed to both sides with no runs of shots to the one side (the dist mean stays centered despite shots going over the arc). Liral, you know how to mod, can you try this? Ideally you would test the significance of the randomness with a runs test, I can do that for you if you want and feel it can't be estimated visually - just need the sequence of shots and which side they went to for statistics.

(I mean, I suppose the alternative would be to ask Alex or someone who knows how spread and turret arcs interact exactly)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 19, 2023, 09:53:34 PM
Thanks for explaining your concern about the over-spread weapon, and sure, I can try modding a weapon to have a 5 degree tracking arc and 180 degree spread.  I understand you want to know what the spread would be and whether the distribution of shots would stay centered--is that so?

Also, you missed two questions. 

1. What should the name and docstring of each function (currently named transformed_angle and transformed_coordinate) be?  I don't understand these short one-sentence descriptions you've written, and I think a follow-up paragraph could elaborate, but I don't know what to write in that paragraph because I still don't quite understand the purpose of either function.

2. How do my new results differ from yours?  The big change I made was to replace the call to the upper_bounds function with the armor grid bounds themselves, perhaps skewing the results.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 19, 2023, 11:09:33 PM
Well, let's say we have a weapon. Its shots are spread over a range of angles with an associated probability distribution. This probability distribution will have a mean. Tracking the target means trying to align this mean with the target. Transform_hit_coord finds where the mean is, from our ship's perspective, as the gun does so. Transformed_angle finds the signed angle from this mean to the target (negative - mean is above target, positive - mean is below target).

I suggest the new names could be transform_hit_coord -> mean_while_tracking and transformed_angle -> angle_from_mean.

Your results are quite different in many dimensions -
Results now

spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 167

Bounds
(0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110)

Distributions
(1.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0)
(0.241, 0.045, 0.048, 0.05, 0.052, 0.052, 0.053, 0.052, 0.051, 0.049, 0.046, 0.043, 0.219)
(0.5, 0.053, 0.052, 0.05, 0.048, 0.046, 0.042, 0.038, 0.034, 0.03, 0.025, 0.021, 0.061)
(0.859, 0.03, 0.026, 0.021, 0.017, 0.013, 0.01, 0.007, 0.005, 0.004, 0.002, 0.002, 0.002)


Our previous found common result:


spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 167

Bounds
(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)

Distributions
(1.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0, -0.0)
(0.008, 0.01, 0.018, 0.029, 0.043, 0.059, 0.073, 0.085, 0.092, 0.096, 0.095, 0.091, 0.082, 0.219)
(0.061, 0.041, 0.056, 0.071, 0.083, 0.092, 0.096, 0.096, 0.092, 0.083, 0.071, 0.056, 0.041, 0.061)
(0.338, 0.093, 0.096, 0.095, 0.09, 0.08, 0.067, 0.052, 0.037, 0.024, 0.014, 0.007, 0.004, 0.002)


It appears the bounds have become quite different here.

Quote
Thanks for explaining your concern about the over-spread weapon, and sure, I can try modding a weapon to have a 5 degree tracking arc and 180 degree spread.  I understand you want to know what the spread would be and whether the distribution of shots would stay centered--is that so?

Yes, that's exactly it. If the current theory is correct then 1) the spread will be 180, rather than 5 and 2) shots will be randomly to both sides without runs to either side (ie. will stay centered). If we deviate from this can modify theory.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 20, 2023, 05:57:58 AM
I had changed the bounds formula to be the cell size times the cell number for the number of cells plus one.  Hardcoding the bounds to what they were before, I got the same result as before.
spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 167

Bounds
(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)

Distributions
(1.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0)
(0.241, 0.045, 0.048, 0.05, 0.052, 0.052, 0.053, 0.052, 0.051, 0.049, 0.046, 0.043, 0.219)
(0.5, 0.053, 0.052, 0.05, 0.048, 0.046, 0.042, 0.038, 0.034, 0.03, 0.025, 0.021, 0.061)
(0.859, 0.03, 0.026, 0.021, 0.017, 0.013, 0.01, 0.007, 0.005, 0.004, 0.002, 0.002, 0.002)

What should the armor bounds formula be?  The difference between armor cell bounds seems to be 18 1/3, and that difference times the number of cells, 12, equals twice the rightmost number.  So, the position of each bound should be its index times the bound distance minus half the product of the bound distance and the number of cells.
Code
number_of_cells = 12
cell_width = 18 + 1/3
right_bound = number_of_cells * cell_width / 2
tuple(i * cell_width - right_bound for i in range(number_of_cells + 1)
>>(-109.99980000000001, -91.66646666666668, -73.33313333333334, -54.99980000000001, -36.66646666666668, -18.33313333333335, 0.00019999999999242846, 18.333533333333307, 36.66686666666665, 55.00019999999999, 73.3335333333333, 91.66686666666665, 110.00019999999999)
which, rounded to three decimal places, are our previous bounds within rounding error.
(-110.0, -91.666, -73.333, -55.0, -36.666, -18.333, 0.0, 18.334, 36.667, 55.0, 73.334, 91.667, 110.0)
Adding this code to the test and changing the armor cell width to 18 + 1/3 yields this result, which I believe to be the same as it was before.
spread, arc, angle
5.0 20.0 -10.0
5.0 20.0 10.0
0.0 20.0 -160.0
0.0 20.0 180.0
0.0 20.0 160.0
5.0 90.0 120.0

minimum and maximum means
-17.5 -2.5
2.5 17.5
-170.0 -150.0
170.0 -170.0
150.0 170.0
77.5 162.5

Optimum Angle: 167

Bounds
(-110.0, -91.667, -73.333, -55.0, -36.667, -18.333, 0.0, 18.333, 36.667, 55.0, 73.333, 91.667, 110.0)

Distributions
(1.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0)
(1.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0)
(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0, -0.0)
(0.008, 0.01, 0.018, 0.029, 0.043, 0.059, 0.073, 0.085, 0.092, 0.096, 0.095, 0.091, 0.082, 0.219)
(0.061, 0.041, 0.056, 0.071, 0.083, 0.092, 0.096, 0.096, 0.092, 0.083, 0.071, 0.056, 0.041, 0.061)
(0.338, 0.093, 0.096, 0.095, 0.09, 0.08, 0.067, 0.052, 0.037, 0.024, 0.014, 0.007, 0.004, 0.002)
If you agree that they are the same, then I will add a results-check to the test.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 20, 2023, 06:42:16 AM
Yes, these results are identical.

The formula to generate upper bounds is:
First bound = -ship width / 2
Increment = ship width / number of cells
Number of bounds = number of cells + 1
Bound 2 = Bound 1 + Increment
Bound n = Bound n-1 + Increment

So, example: dominator
First bound = - 220/2 = -110
Increment = 220 / 12 = 55/3 = 18 and 1/3 (18.33333...)
Second bound = -110 + 18 and 1/3 = -91 and 2/3
Third bound = -91 and 2/3 + 18 and 1/3 = -73 and 1/3

and so forth. So you calculated it correctly.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 20, 2023, 11:06:18 AM
Uh-oh, bad news.  That 10 pixel armor cell size we've used in testing isn't possible because the armor cell size formula is
Code
cell_size = 15 if height < 150 else height/10 if height < 300 else 30
You'll have to run your armor grid generator code again with the new formula and post the results.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 20, 2023, 11:37:52 AM
Hm? The Dominator height is given as 180 px in dominator.ship. So its cells should be 18 px wide. Still, good catch, 18 px is not 18.333... px.

I guess I really don't know how it works after all, because look at this thing:
(https://i.ibb.co/618HkLH/image.png) (https://imgbb.com/)

It has 12 cells across. This should not be possible if it is 220 wide, 180 tall, and therefore 1 cell is 18 px wide. Any clues how to interpret this?

Anyway the formula above to generate ship cells is saying that
First bound = -ship width / 2 #the first cell starts at the left edge of the ship
Increment = ship width / number of cells #if you divide the ship's width by how many cells it has across you get the width of one cell
Number of bounds = number of cells + 1 #every cell has one upper bound, and in addition a lower bound, which is not another cell's upper bound for one cell only. these are all the boundaries of the cells of the ship (in one row in the horizontal direction)
Bound 2 = Bound 1 + Increment #the next cell follows after the width of the previous cell
Bound n = Bound n-1 + Increment

Looking at that and then looking at the picture, I guess step #2 might be wrong because cells can be partial and in that case you would not get the width of one complete cell from the division. Then the algorithm should instead read like this:

Start at 0
If the ship has an odd number of cells, start with a vector containing two boundaries at half the size of one cell to either side. (e.g. [-9 9]), if an even number, with one containing 0 ([0])
While you are below shipwidth/2 (above -shipwidth/2) concatenate to right (to left) the value of the previous index +cell width (next index -cell width)
(e.g. [-99 -81 -63 -45 -27 -9 9 27 45 63 81 99])
Concatenate shipwidth/2 to right (-shipwidth/2 to left)
(e.g [-110 -99 -81 -63 -45 -27 -9 9 27 45 63 81 99 110])

However that does not explain what is going on here. By the formula there should be one extra partial cell on either side instead (applying the above algorithm we'd get [-110 -108 -90 -72 -54 -36 -18 0 18 36 54 72 90 108 110] for the Dommy. So let's figure that out before writing more code for a change. I was thinking maybe the game drops "sliver" cells only a few px wide but that doesn't seem to be the case (look closely at the image).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 20, 2023, 12:56:48 PM
If the cell is 18 pixels wide and ship 220, then the armor grid of the ship should be 220 / 18 = 12.2222 cells across.  My code rounds the cell count down to the nearest integer, yielding 12 as seen in-game.

Code
width, height = 220, 180
cell_size = 15 if height < 150 else height / 10 if height < 300 else 300
cells_across = int(width / cell_size)
print(cells_across)
12
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 20, 2023, 10:05:00 PM
I have an alternative proposal based on close observation of the sprite. Note that the final cell is partial, not whole. This is consistent with the armor grid only extending to the collision bound (in the dominator's case -102, 103) rather than to the edge of the sprite. Sorry, I am a little short on time now, so not much time to expain. Here, however, is the code based on this idea.


generate_ship_upper_bounds <- function(left_collision_bound, right_collision_bound, length){
  #this operation centers the target to a whole pixel
  ship_lower_bound <- -floor((abs(left_collision_bound)+abs(right_collision_bound))/2)
  ship_upper_bound <- ceiling((abs(left_collision_bound)+abs(right_collision_bound))/2)
  cell_size <- 15
  #we assume there can be no fractions of a pixel, so floor
  if(length >= 150) cell_size <- floor(length / 10)
  if(length >= 300) cell_size <- 30
  #we have enough cells to reach exactly or go over the collision bounds, soceil
  no_cells <- ceiling((ship_upper_bound-ship_lower_bound)/cell_size)
  #the starting vector differs for odd and even counts
  if(no_cells %% 2 == 0) ub_vector <- c(0)
  #we add a floor and ceiling operation here to keep to integers - equivalent to us aiming at a whole pixel
  else ub_vector <- c(-floor(cell_size/2), ceiling(cell_size/2))
  #concatenate positive upper bounds
  while(ub_vector[length(ub_vector)] + cell_size < ship_upper_bound) ub_vector <- c(ub_vector, ub_vector[length(ub_vector)]+cell_size)
  ub_vector <- c(ub_vector, ship_upper_bound)
  #concatenate negative upper bounds
  while(ub_vector[1] - cell_size > ship_lower_bound) ub_vector <- c(ub_vector[1]-cell_size, ub_vector)
  ub_vector <- c(ship_lower_bound, ub_vector)
  return(ub_vector)
}


Output for parameters from dominator.ship


> generate_ship_upper_bounds(-102,103,180)
 [1] -102  -90  -72  -54  -36  -18    0   18   36   54   72   90  103


To-do: take a screenshot of the game at native resolution with the Dominator in the lower left horizontal and measure pixels to see if this is exactly correct.

Here is R code without loops that might be computationally advantageous but may not translate cleanly, however you can see if something similar can be done in Py. Provided we believe this is the correct method to generate bounds, that is.


generate_ship_upper_bounds <- function(left_collision_bound, right_collision_bound, length){
  #this operation centers the target to a whole pixel
  ship_lower_bound <- -floor((abs(left_collision_bound)+abs(right_collision_bound))/2)
  ship_upper_bound <- ceiling((abs(left_collision_bound)+abs(right_collision_bound))/2)
  cell_size <- 15
  #we assume there can be no fractions of a pixel, so floor
  if(length >= 150) cell_size <- floor(length / 10)
  if(length >= 300) cell_size <- 30
  #we have enough cells to reach exactly or go over the collision bounds, soceil
  no_cells <- ceiling((ship_upper_bound-ship_lower_bound)/cell_size)
  #the starting vector differs for odd and even counts
  if(no_cells %% 2 == 0) ub_vector <- c(0)
  #we add a floor and ceiling operation here to keep to integers - equivalent to us aiming at a whole pixel
  else ub_vector <- c(-floor(cell_size/2), ceiling(cell_size/2))
  #concatenate upper bounds
  ub_vector <- unique(c(ship_lower_bound,
  rev(-seq(-ub_vector[1],-ship_lower_bound,cell_size)),
  ub_vector,
  seq(ub_vector[length(ub_vector)],ship_upper_bound,cell_size),
  ship_upper_bound))
  return(ub_vector)
}
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 21, 2023, 12:22:07 AM
Before trying to write partial cell code, we should ask Alex if partial cells exist.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 21, 2023, 12:28:27 AM
Oh that's easy, just look at the in-game image I posted above. Doesn't it clearly have partial cells? Or do you mean that they can actually be hit like full cells despite looking like they're less than?

I sent a PM to Alex about whether I have it right here and how the partial cells can be hit.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 21, 2023, 04:36:18 AM
(https://i.ibb.co/pQTrcvy/image.png) (https://imgbb.com/)

So here are the pixel coordinates in this snapshot of the in-game interface according to MSPaint. Note that in this we have the ship width as 65 or 64 and 1 cell is 11 px wide except the most lateral is 9 px. If we scale this to the actual width we get a scaling factor of 102/64 or 103/65, so we get these boundaries:


64, scaling factor 102/64
> bound <- 64
> width <- 102
> round(c(seq(0,bound,11),bound)*width/bound,2)
[1]   0.00  17.53  35.06  52.59  70.12  87.66 102.00

65, scaling factor 102/65
> bound <- 65
> width <- 102
> round(c(seq(0,bound,11),bound)*width/bound,2)
[1]   0.00  17.26  34.52  51.78  69.05  86.31 102.00

64, scaling factor 103/64
> bound <- 64
> width <- 103
> round(c(seq(0,bound,11),bound)*width/bound,2)
[1]   0.00  17.70  35.41  53.11  70.81  88.52 103.00

65, scaling factor 103/65
> bound <- 65
> width <- 103
> round(c(seq(0,bound,11),bound)*width/bound,2)
[1]   0.00  17.43  34.86  52.29  69.72  87.15 103.00

64.5, scaling factor 102.5/64.5
> round(c(seq(0,bound,11),bound)*width/bound,2)
[1]   0.00  17.48  34.96  52.44  69.92  87.40 102.50


It appears that no matter which values we choose for this it won't line up with the results above. However, what if we base this on the length of the ship according to collision bounds rather than the stated length? According to dominator.ship the real ship length (that is the y distance between the highest and lowest points of the ship, from the gun placements to the pointy rear) appears to be 97-(-72)=169 rather than 180.

Plugging this into the upper bounds function above we get this

> generate_ship_upper_bounds(-102,103,169)
 [1] -102  -93  -76  -59  -42  -25   -8    9   26   43   60   77   94  103

Yeah that's obviously not right either.

We could obviously insert an arbitrary scaling factor other than 10, say we divide 180 by 10.3 or 169 by 9.5 to get the real values, but that is very confusing and likely to go wrong because a) we don't know which value to use and b) why would Alex choose a value like that?

Ideas?

If we do accept the version with a first bound of 17.7 then we are very close with
> generate_ship_upper_bounds(-102,103,180)
 [1] -102  -90  -72  -54  -36  -18    0   18   36   54   72   90  103
though

(the measured result there is

> bound <- 64
> width <- 103
> round(c(seq(0,bound,11),bound)*width/bound,2)
[1]   0.00  17.70  35.41  53.11  70.81  88.52 103.00
)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 21, 2023, 09:51:54 AM
Oh that's easy, just look at the in-game image I posted above. Doesn't it clearly have partial cells? Or do you mean that they can actually be hit like full cells despite looking like they're less than?

I sent a PM to Alex about whether I have it right here and how the partial cells can be hit.

The latter!  I figure the partial cells might be 'padding' cells 'creeping' into the image.  Anyhow, Alex probably has some simple system because it's less effort.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 21, 2023, 10:52:31 AM
Got a prompt reply from Alex. Can't help but admire that he takes the time!

Anyway it is as theorized. 1/10 sprite height, maximum 30, minimum 15 for armor cell size. There are enough cells to cover the sprite +2 on either side. Collision bounds determine how cells get hit with no unexpected interactions. So the bounds we are looking for for the dominator are in fact exactly

> generate_ship_upper_bounds(-102,103,180)
 [1] -102  -90  -72  -54  -36  -18    0   18   36   54   72   90  103

Because the cells are 18 wide, but the final cell can't be hit beyond the collision bound (-102/+103).
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 21, 2023, 11:39:30 AM
Can ships have odd numbers of armor cells, or must the number be even?  If we need only the even case, we can write:
Code
height = 180
#"width" field in .ship file
sprite_width = 220
#calculated by subtracting leftmost from rightmost x coordinate
#in "bounds" field of .ship file
bounds_width = 206
cell_size = 15 if height < 100 else height / 10 if height < 300 else 30
distance = 0
bounds = [i * cell_size for i in range(int(sprite_width / 2 / cell_size) + 1)]
bounds[-1] = min(bounds[-1], bounds_width / 2)
bounds = tuple(-bound for bound in reversed(bounds[1:])) + tuple(bounds)
print(bounds)
(-103.0, -90.0, -72.0, -54.0, -36.0, -18.0, 0.0, 18.0, 36.0, 54.0, 72.0, 90.0, 103.0)
Please tell me whether my numbers and their sources are right; note that I don't get quite the same left bound as you do.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 21, 2023, 12:38:22 PM
Yeah ships can have an odd number of bounds. However, in vanilla this appears to happen only for asymmetric designs as it seems like the cells start at the ship's center and extend both ways. This Vigilance has 5 armor cells, however, the 5th is only a sliver. Note that it is 15 up to 150 height, not 100.

(https://i.ibb.co/wcZ5b3M/image.png) (https://imgbb.com/)

I don't know why you get 206, the leftmost coordinate I can find in dominator.ship is -102 and the rightmost is 103, so it should be 205 wide.

Dominator

> generate_ship_upper_bounds(-102,103,180)
 [1] -102  -90  -72  -54  -36  -18    0   18   36   54   72   90  103

Vigilance

> generate_ship_upper_bounds(-27,24,101)
[1] -25 -13   2  17  26


The reason why my code puts the middle cell slightly off center is that we aim at the midpoint of the ship's collision bounds rather than at the midpoint of the collision bound coordinates in the case of this asymmetric ship.

Wait, that's only 4 cells for a Vigilance.  Nevermind we must be using midpoint of the sprite again. Alex did say the armor grid does not interact with the collision bounds in any other way other than determining when the cells are hit. So we need an extra parameter for sprite width.
incorrect code

generate_ship_upper_bounds <- function(left_collision_bound, right_collision_bound, length, width){
  #this operation centers the target to a whole pixel
  ship_lower_bound <- -floor((abs(left_collision_bound)+abs(right_collision_bound))/2)
  ship_upper_bound <- ceiling((abs(left_collision_bound)+abs(right_collision_bound))/2)
  midpoint <- ship_lower_bound + ceiling(width/2)
  cell_size <- 15
  #we assume there can be no fractions of a pixel, so floor
  if(length >= 150) cell_size <- floor(length / 10)
  if(length >= 300) cell_size <- 30
  #we have enough cells to reach exactly or go over the collision bounds, soceil
  no_cells <- ceiling((ship_upper_bound-ship_lower_bound)/cell_size)
  #start at the midpoint
  ub_vector <- c(midpoint)
  #concatenate positive upper bounds
  while(ub_vector[length(ub_vector)] + cell_size < ship_upper_bound) ub_vector <- c(ub_vector, ub_vector[length(ub_vector)]+cell_size)
  ub_vector <- c(ub_vector, ship_upper_bound)
  #concatenate negative upper bounds
  while(ub_vector[1] - cell_size > ship_lower_bound) ub_vector <- c(ub_vector[1]-cell_size, ub_vector)
  ub_vector <- c(ship_lower_bound, ub_vector)
  return(ub_vector)
}

[close]
Vigilance

> generate_ship_upper_bounds(-27,24,101, 63)
[1] -25 -23  -8   7  22  26

Yeah that's 5 cells like in the picture. However, this now gives incorrect results for the Dominator:


> generate_ship_upper_bounds(-102,103,180, 220)
 [1] -102 -100  -82  -64  -46  -28  -10    8   26   44   62   80   98  103


Damn. Well, maybe it's just a visual bug in the armor grid visualization. Going back to the previous method...

generate_ship_upper_bounds <- function(left_collision_bound, right_collision_bound, length){
  #this operation centers the target to a whole pixel
  ship_lower_bound <- -floor((abs(left_collision_bound)+abs(right_collision_bound))/2)
  ship_upper_bound <- ceiling((abs(left_collision_bound)+abs(right_collision_bound))/2)
 
  cell_size <- min(30,max(15,length/10))
  #number of cells
  #try to start at point 0
  midpoint <- ship_lower_bound+abs(left_collision_bound)

  ub_vector <- c(midpoint)
  #concatenate positive upper bounds
  while(ub_vector[length(ub_vector)] + cell_size < ship_upper_bound) ub_vector <- c(ub_vector, ub_vector[length(ub_vector)]+cell_size)
  ub_vector <- c(ub_vector, ship_upper_bound)
  #concatenate negative upper bounds
  while(ub_vector[1] - cell_size > ship_lower_bound) ub_vector <- c(ub_vector[1]-cell_size, ub_vector)
  ub_vector <- c(ship_lower_bound, ub_vector)
 
  return(ub_vector)
}


Dominator

> generate_ship_upper_bounds(-102,103,180)
 [1] -102  -90  -72  -54  -36  -18    0   18   36   54   72   90  103


Vigilance

> generate_ship_upper_bounds(-27,24,101)
[1] -25 -13   2  17  26
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 21, 2023, 02:20:47 PM
Quote
Well, maybe it's just a visual bug in the armor grid visualization.

Let's ask Alex again to find out.  Maybe we've discovered a bug!  8)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 22, 2023, 09:52:50 PM
It is not a bug. Alex replied and said this uses the midpoint defined in the .ship file. I will post corrected code shortly, I'll have time to write it on the bus.

I also asked about whether spread can kick the gun beyond its arc. Alex said he thinks so but unlike previous did not cite source code. So we are going to need to do some empirical science on this. I have the idea for another test. These will require a modded weapon with a spread of 180, a ship with a 5 degree (or any small nonzero) tracking arc mount and a ship with a 180 degree tracking arc mount.

Two tests:
1) mount the modded gun in the large tracking arc mount, position the target to one side (direct left or right, so it is at the edge of the arc) and fire at it. One of three things will happen:
1.1) the gun will fire randomly in a 180 degree spread centered on the target. This means tracking arcs are not inviolable and spread is constant.
1.2) the gun will fire randomly in a 90 degree spread from target to gun facing. This means tracking arc is constant and spread is not constant.
1.3) the gun will fire randomly in the 180 degree arc of the gun. This means tracking arc and spread are constant and is the model we have now.

2) mount the modded gun in the small tracking arc mount and position so that the target is directly to the left or right of the tracking arc (so 90 or -90 degrees from gun to target). Again we will get one of three things
2.1) fire is restricted to the 5 degree arc. This means tracking arcs are inviolable and spread is not constant.
2.2) fire is randomly within a 180 degree arc with the midpoint in the gun mounts arc. This means spread is constant at the cost of the tracking arc and is the model we have now.
2.3) fire is spread non-randomly beyond the 5 degree arc (likely either in the 90 degree arc towards or away from the target). This means the interaction between spread and arc leads to pseudo-tracking (the gun can rotate 5 degrees to track the target and the recoil can kick it beyond this arc, but it still continues to track, leading to complex behavior).

I know how to adjust the model in each case but can't decide which is correct without empirical data.

Edit to add: these tests apply to continuous fire only so the gun should preferably be relatively rapid firing and the mouse button should be held down for the duration of the test.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 22, 2023, 10:20:44 PM
Well, actually it probably is a visual bug because here is the code that does what Alex says it should:


generate_ship_upper_bounds <- function(left_collision_bound, right_collision_bound, length){
  #this operation centers the target to a whole pixel
  #we will fire at the center of the ship, so center = 0

  cell_size <- min(30,max(15,length/10))
  ub_vector <- c(0)
  #concatenate positive upper bounds
  while(ub_vector[length(ub_vector)] + cell_size < right_collision_bound) {
    ub_vector <- c(ub_vector, ub_vector[length(ub_vector)]+cell_size)
  }
  ub_vector <- c(ub_vector, right_collision_bound)
  #concatenate negative upper bounds
  while(ub_vector[1] - cell_size > left_collision_bound) ub_vector <- c(ub_vector[1]-cell_size, ub_vector)
  ub_vector <- c(left_collision_bound, ub_vector)
 
  return(ub_vector)
}

Vigilance

> generate_ship_upper_bounds(-27,24,101)
[1] -27 -15   0  15  24


Not the same as the image since this is 4 cells without a 1 px sliver. Probably doesn't matter since we know what the bounds are now.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 23, 2023, 07:57:58 AM
Well, actually it probably is a visual bug because here is the code that does what Alex says it should:


generate_ship_upper_bounds <- function(left_collision_bound, right_collision_bound, length){
  #this operation centers the target to a whole pixel
  #we will fire at the center of the ship, so center = 0

  cell_size <- min(30,max(15,length/10))
  ub_vector <- c(0)
  #concatenate positive upper bounds
  while(ub_vector[length(ub_vector)] + cell_size < right_collision_bound) {
    ub_vector <- c(ub_vector, ub_vector[length(ub_vector)]+cell_size)
  }
  ub_vector <- c(ub_vector, right_collision_bound)
  #concatenate negative upper bounds
  while(ub_vector[1] - cell_size > left_collision_bound) ub_vector <- c(ub_vector[1]-cell_size, ub_vector)
  ub_vector <- c(left_collision_bound, ub_vector)
 
  return(ub_vector)
}

Vigilance

> generate_ship_upper_bounds(-27,24,101)
[1] -27 -15   0  15  24


Not the same as the image since this is 4 cells without a 1 px sliver. Probably doesn't matter since we know what the bounds are now.

I guess we should report it, then!  Meanwhile, I've translated it to Python.

Code
import numpy as np

left, right = -27, 24
height = 101
size = 15 if height < 150 else height / 10 if height < 300 else 30
bounds = np.array((left, *(i * size for i in range(int(left / size),
                                                   int(right / size) + 1)),
                   right))
print(bounds)
[-27 -15.    0.   15.   24. ]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 23, 2023, 09:46:32 AM
Oh, I think I got it. What we see in the image are not the collision bounds, but the sprite, which is wider, explaining the inconsistency. Alex may not want to show collision bounds as those are not visually as recognizable. So not a bug.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 23, 2023, 11:51:20 AM
Wait, I thought you said you saw 'slivers' of armor cells around the edges of the ship.  ???
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 23, 2023, 07:48:50 PM
Yes. If you look at the image of the Vigilance I posted above you can see a 1 px wide partial armor cell to the left of its widest part. It displays a total of 5 horizontal armor cells with this partial one included. This does not correspond to our calculation of 4 armor cells. However, we are calculating within collision bounds, which indeed determine if the ship gets hit according to Alex. But that image is clearly displaying the outline of the Vigilance sprite, and armor grid bounds within the sprite, not the collision bounds of the Vigilance and armor grid within them. This is why we don't get 5 cells like the image: there aren't 5 hittable cells, unlike the image (the sprite is wider). But it is likely not a bug, since the collision bounds are a simplified polygon in the shape of the ship and wouldn't look good to the player. Hence it's more likely a deliberate decision by Alex to show the armor grid within the sprite rather than collision bounds which are usually close
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: Liral on January 24, 2023, 05:23:56 AM
So, with all that settled, I guess we should decide what distributions to expect.  I can't quite get the same ones.

Code
Code: analysis.py
"""
Calculate the optimum angle to place the enemy ship with regard to ours.

Assume
- possible angles are integers from -179 to 180
- angle exactly to the right is 0 degrees
- our ship is
  - heading towards +90 degrees
  - pointlike
- the enemy ship is
  - a single line of hittable armor cells
  - at constant range
  - oriented tangentially to the circle centered on our ship
- the secant from one armor cell on the enemy ship to another approximates
  the arc between them
"""
from statistics import NormalDist
import math



WEAPON_SCORES = {
    ("LARGE", False) : 7.0,
    ("MEDIUM", False) : 3.0,
    ("SMALL", False) : 1.0,
    ("LARGE", True) : 3.5,
    ("MEDIUM", True) : 1.5,
    ("SMALL", True) : 0.5
}


def probability_hit_within(
        x: float,
        standard_deviation: float,
        uniform_distribution_width: float) -> float:
    """
    Return the probability to hit a coordinate less than x.
   
    x - real number
    standard_deviation - of the normal distribution N(0,a),
    uniform_distribution_width - of the symmetric uniform distribution (-b,b)
    """
    if standard_deviation == 0 and uniform_distribution_width == 0:
        return 0 if x < 0 else 1
    if standard_deviation == 0:
        return max(0, min(1, (1 + x / uniform_distribution_width) / 2))
    if uniform_distribution_width == 0:
        return NormalDist(0, standard_deviation).cdf(x)
    a = (x - uniform_distribution_width) / standard_deviation
    b = (x + uniform_distribution_width) / standard_deviation
    normal_distribution = NormalDist(0, 1)
    cdf, pdf = normal_distribution.cdf, normal_distribution.pdf
    return (standard_deviation / 2 / uniform_distribution_width
            * (b * cdf(b) + pdf(b) - (a * cdf(a) + pdf(a))))
   
   
def deg_to_arc(degree: float, radius: float) -> float:
    """
    Return the arc corresponding to the central angle of a circle
    of this radius.
   
    degree - central angle of the arc in degrees
    radius - from the center of the circle to the edge
    """
    return degree * 2 * radius / 360 * math.pi


def arc_to_deg(arc: float, radius: float) -> float:
    """
    Return the central angle corresponding to an arc of the circle of
    this radius.
   
    arc - across the edge of a circle
    radius - from the center of the circle to the edge
    """
    return arc / 2 / radius * 360 / math.pi
   
   
def minimum_mean(spread: float, angle: float, arc: float):
    """
    Return the minimum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    minimum_mean = angle - (arc - spread) / 2
    if minimum_mean > 180: minimum_mean -= 360
    elif minimum_mean < -179: minimum_mean += 360
    return minimum_mean
   
   
def maximum_mean(spread: float, angle: float, arc: float):
    """
    Return the maximum mean hit probability of a weapon in a slot.

    spread - of the weapon
    facing - of the slot
    tracking_range - of the slot
    """
    maximum_mean = angle + (arc - spread) / 2
    if maximum_mean > 180: maximum_mean -= 360
    elif maximum_mean < -179: maximum_mean += 360
    return maximum_mean
   
   
def minimum_and_maximum_means(weapons: tuple) -> tuple:
    """
    Reurn the minimum and maximum means of a tuple of Weapons.
    """
    minimum_means, maximum_means = [], []
    for weapon in weapons:
        if weapon["spread"] < weapon.slot["arc"]:
            minimum_means.append(minimum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
            maximum_means.append(maximum_mean(weapon["spread"],
                                              weapon.slot["angle"],
                                              weapon.slot["arc"]))
        else:
            minimum_means.append(weapon.slot["angle"])
            maximum_means.append(weapon.slot["angle"])
    return tuple(minimum_means), tuple(maximum_means)
   

def transformed_hit_coordinate(
        target_positional_angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the mean of the distribution of the probability of a weapon
    to hit a target positioned at an angle.
   
    target_positional_angle - angle from the direction the shooter faces
                              to the direction from the shooter to the
                              target
    minimum_mean - minimum mean hit probability of this weapon
    maximum_mean - maximum mean hit probability of this weapon
    """
    #weapon arc does not include 180 to  - 179
    if maximum_mean >= minimum_mean:
        if minimum_mean <= target_positional_angle <= maximum_mean:
            return target_positional_angle
        a = abs(target_positional_angle - maximum_mean)
        b = abs(target_positional_angle - minimum_mean)
        return (minimum_mean if min(a, 360 - a) > min(b, 360 - b) else
                maximum_mean)
    #weapon arc includes 180 to  - 179 but does not cross 0
    if maximum_mean <= 0 <= minimum_mean:
        if target_positional_angle < 0:
            if target_positional_angle < maximum_mean:
                return target_positional_angle
            if (abs(target_positional_angle - minimum_mean)
                >= abs(target_positional_angle - maximum_mean)):
                return maximum_mean
            return minimum_mean
        if target_positional_angle > minimum_mean:
            return target_positional_angle
        if (abs(target_positional_angle - minimum_mean)
            >= target_positional_angle - maximum_mean):
            return maximum_mean
        return minimum_mean
       
    #weapon arc includes 0 and 180 to  - 179
    if maximum_mean <= target_positional_angle <= minimum_mean:
        if target_positional_angle < 0:
            if (abs(target_positional_angle - maximum_mean)
                > abs(target_positional_angle - minimum_mean)):
                return minimum_mean
            return maximum_mean
        return (minimum_mean if target_positional_angle - maximum_mean
                                > abs(target_positional_angle - minimum_mean)
                else maximum_mean)
    return target_positional_angle


def transformed_angle(
        target_positional_angle: float,
        minimum_mean: float,
        maximum_mean: float) -> float:
    """
    Return the angle of the target relative to this weapon.
   
    target_positional_angle - angle from the direction the shooter faces to
                              to the direction from the shooter target to the
                              target
    minimum_mean - minimum mean hit probability of this weapon
    maximum_mean - maximum mean hit probability of this weapon
    """
    return target_positional_angle - transformed_hit_coordinate(
        target_positional_angle, minimum_mean, maximum_mean)


def probability_hit_at_angle(
        weapon_angle: float,
        spread_angle: float,
        target_angular_size: float,
        target_positional_angle: float,
        target_positional_angle_error: float) -> float:
    """
    Return the probability for a weapon of this spread and facing this
    direction to hit a target of this angular size positioned at this angle
    with this standard deviation thereof.
   
    weapon_angle - direction the weapon is pointing (-179, 180)
    spread_angle - uniform angular dispersion of weapon shot facing
    target_angular_size - size of the target in degrees
    target_positional_angle - location of the target (-179, 180)
    target_positional_angle_error - standard deviation of
                                    target_positional_angle
    """
    upper_bound = weapon_angle + target_angular_size
    lower_bound = weapon_angle - target_angular_size
    return (probability_hit_within(upper_bound, target_positional_angle_error,
                                   spread_angle)
            - probability_hit_within(lower_bound, target_positional_angle_error,
                                     spread_angle))
                         
                                           
def total_probable_weapon_score_at_angles(
        weapons: tuple,
        minimum_means: tuple,
        maximum_means: tuple,
        target_width: float,
        distance: float,
        standard_deviation: float) -> tuple:                                       
    #now, for angles -359 to 360 (all possible signed angles)
    target_positional_angles = (*(i for i in range(-179, 180)),)
    target_angular_size = arc_to_deg(target_width, distance) / 2
    target_positional_angle_error = arc_to_deg(standard_deviation, distance)
    weapon_scores = tuple(WEAPON_SCORES[weapon["size"], weapon["pd"]]
                          for weapon in weapons)
    total_probable_weapon_score_at_angles = []
    for target_positional_angle in target_positional_angles:
        total_probable_weapon_score = 0
        for i, weapon in enumerate(weapons):
            weapon_angle = (0 if weapon.slot["arc"] == 360
                            else transformed_angle(target_positional_angle,
                                              minimum_means[i],
                                              maximum_means[i]))
            probability = probability_hit_at_angle(weapon_angle,
                weapon["spread"], target_angular_size, target_positional_angle,
                target_positional_angle_error)
            total_probable_weapon_score += probability * weapon_scores[i]
        total_probable_weapon_score_at_angles.append(
            total_probable_weapon_score)
    return total_probable_weapon_score_at_angles


def middle_index_of_approximate_maxima(row: tuple) -> int:
    """
    Return the middle index of those indices where the row is nearly maximum.
   
    row - a row containing real numbers
    """
    rounded_row = tuple(round(element, 5) for element in row)
    indicies_of_approximate_maxima = (*(i for i, x in enumerate(rounded_row)
                                           if x == max(rounded_row)),)
    middle = len(indicies_of_approximate_maxima) // 2
    return indicies_of_approximate_maxima[middle]
   
   
def optimum_angle(
        weapons: tuple,
        minimum_means: tuple,
        maximum_means: tuple,
        target_width: float,
        distance: float,
        standard_deviation: float) -> float:
    #we use a separate vector to keep track of angle, since vector
    #index 1 corresponds to angle -179 now
    x_axis = range(-179, 180)
    optimum_angle_index = middle_index_of_approximate_maxima(
        total_probable_weapon_score_at_angles(weapons, minimum_means,
        maximum_means, target_width, distance, standard_deviation))
    return x_axis[optimum_angle_index]


def hit_distribution(
        bounds: tuple,
        standard_deviation: float,
        spread_distance: float) -> tuple:
    """
    Return the hit distribution.
   
    The hit distribution is a tuple of probability masses wherein
    the first value is the chance to hit below lowest upper bound,
    the last value is chance to hit above highest upper bound, and the
    others are the probabilities for hits between upper bounds,
    adjusted for ship location.
   
    bounds - a tuple of upper bounds
    standard deviation - of a normal distribution N(0,a),
    spread_distance - a parameter of a symmetric uniform distribution
                      (-spread, spread)
    """
    if standard_deviation == 0 and spread_distance == 0:
        #all shots hit 1 cell even if the ship has evenly many to
        #prevent such ships from seeming tougher
       
        return (0, *(1 if bound < 0 <= bounds[i+1] else 0 for i, bound in
                     enumerate(bounds[:-1])))
    elif standard_deviation == 0: #return part of a box
        a = 2 * spread_distance
        return (min(1, max(0, (bounds[0] + spread_distance)) / a),
                *((min(1, max(0, (bounds[i+1] + spread_distance)) / a)
                   - min(1, max(0, (bound + spread_distance)) / a))
                  for i, bound in enumerate(bounds[:-1])),
                1 - min(1, max(0, (bounds[-1] + spread_distance)) / a),)
    elif spread_distance == 0: #normal distribution
        cdf = NormalDist(0, standard_deviation).cdf
        return (cdf(bounds[0]),
                *(cdf(bounds[i+1]) - cdf(bound) for i, bound in
                  enumerate(bounds[:-1])),
                1 - cdf(bounds[-1],))
    return (probability_hit_within(bounds[0], standard_deviation,
                                   spread_distance),
            *(probability_hit_within(bounds[i+1], standard_deviation,
                                     spread_distance)
              - probability_hit_within(bound, standard_deviation,
                                       spread_distance)
              for i, bound in enumerate(bounds[:-1])),
            1 - probability_hit_within(bounds[-1], standard_deviation,
                                       spread_distance))
   
   
def distribution(
        spread: float,
        arc: float,
        minimum_mean: float,
        maximum_mean: float,
        target_positional_angle: float,
        bounds: tuple,
        distance: float,
        standard_deviation: float) -> tuple:
    """
    Return the probability of a weapon of this spread to hit between each
    pair of the bounds of an armor row positioned at this angle and
    distance
   
    as well as of a miss due to hitting below ship's lowest bound in the
    first cell or above ship's highest bound in the last one.
   
    spread - of the weapon
    arc - of the weapon slot
    minimum_mean - minimum_mean of this weapon's probable score at this
                    angle
    maximum_mean - maximum_mean of this weapon's probable score at this
                    angle
    bounds - of the armor grid cells of the target
    distance - range to target
    standard deviation - of target position
    """
    angle_difference = (0 if arc == 360 else transformed_angle(
        target_positional_angle, minimum_mean, maximum_mean))
    adjustment = deg_to_arc(angle_difference, distance)
    adjusted_bounds = (*(bound + adjustment for bound in bounds),)
    distribution = hit_distribution(adjusted_bounds, standard_deviation,
                                    deg_to_arc(spread, distance))
    return distribution
Code: test_analysis.py
import analysis
import numpy as np

def test_analysis():
    class TestWeapon:
        def __init__(self, spread: float, pd: bool, size: str, slot: object):
            self._data = {"spread" : spread, "pd" : pd, "size" : size}
            self.slot = slot

        def __getitem__(self, name: str): return self._data[name]

    class TestSlot:
        def __init__(self, angle: float, arc: float):
            self._data = {"angle" : angle, "arc" : arc}

        def __getitem__(self, name: str): return self._data[name]

    class TestArmorGrid:
        def __init__(self, cells_across: int, cell_size: float):
            self.cells = [[i for i in range(cells_across)]]
            first_bound = - cells_across * cell_size / 2
            self.bounds = tuple(i * cell_size + first_bound for i in range(
                                cells_across + 1))
           
    class TestTarget:
        def __init__(self, width: float):
            self._data = {"width" : width}
            self.armor_grid = None

        def __getitem__(self, name: str): return self._data[name]
   
    test_weapons = (TestWeapon(5.0, False, "LARGE", TestSlot(-10.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(10.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(-160.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(180.0, 20.0)),
                    TestWeapon(0.0, False, "LARGE", TestSlot(160.0, 20.0)),
                    TestWeapon(5.0, False, "LARGE", TestSlot(120.0, 90.0)),)

    test_target = TestTarget(220)
    test_target.armor_grid = TestArmorGrid(12, 18 + 1/3)
    standard_deviation = 50
    distance = 1000
    #weapons
    print("spread, arc, angle")
    for weapon in test_weapons:
        print(weapon["spread"], weapon.slot["arc"], weapon.slot["angle"])
    print()
    #means
    minimum_means, maximum_means = analysis.minimum_and_maximum_means(
        test_weapons)
    print("minimum and maximum means")
    for min_mean, max_mean in zip(minimum_means, maximum_means):
        print(min_mean, max_mean)
    print()
    assert minimum_means == (-17.5, 2.5, -170.0, 170.0, 150.0, 77.5)
    assert maximum_means == (-2.5, 17.5, -150.0, -170.0, 170.0, 162.5)
    #optimum angle
    optimum_angle = analysis.optimum_angle(test_weapons, minimum_means, maximum_means,
                                  test_target["width"], distance,
                                  standard_deviation)
    print("Optimum Angle:", optimum_angle)
    print()
    assert optimum_angle == 167
    #bounds
    bounds = test_target.armor_grid.bounds
    print("Bounds")
    print(tuple(round(bound, 3) for bound in bounds))
    assert tuple(round(bound, 3) for bound in bounds) == (-110.0, -91.667,
                                                          -73.333, -55.0,
                                                          -36.667, -18.333,
                                                          0.0, 18.333, 36.667,
                                                          55.0, 73.333, 91.667,
                                                          110.0)   
    print()
    #distributions
    distributions = np.array(tuple(analysis.distribution(weapon["spread"],
                                                weapon.slot["arc"],
                                                minimum_means[i],
                                                maximum_means[i],
                                                optimum_angle,
                                                test_target.armor_grid.bounds,
                                                distance,
                                                standard_deviation)
                          for i, weapon in enumerate(test_weapons)))
    print("Distributions")
    expected_distributions = np.array((
        (1.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0,
         0.0),
        (1.0, 0.0, 0.0, -0.0, 0.0, 0.0, 0.0, -0.0, 0.0, 0.0, -0.0, 0.0, 0.0,
         0.0),
        (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.0,
         -0.0),
        (0.008, 0.01, 0.018, 0.029, 0.043, 0.059, 0.073, 0.085, 0.092, 0.096,
         0.095, 0.091, 0.082, 0.219),
        (0.061, 0.041, 0.056, 0.071, 0.083, 0.092, 0.096, 0.096, 0.092, 0.083,
         0.071, 0.056, 0.041, 0.061),
        (0.338, 0.093, 0.096, 0.095, 0.09, 0.08, 0.067, 0.052, 0.037, 0.024,
         0.014, 0.007, 0.004, 0.002)
    ))
    print("got")       
    print(np.round(np.array(distributions), 3))
    print("expected")
    print(expected_distributions)
    print("difference")
    print(np.round(np.array(distributions), 3) - expected_distributions)
    difference = distributions - expected_distributions
    assert difference.all() < 0.01
   
[close]
Result
Distributions
got
[[ 1.    -0.     0.     0.     0.    -0.     0.     0.     0.    -0.
   0.     0.    -0.     0.   ]
 [ 1.     0.     0.    -0.     0.     0.     0.    -0.     0.     0.
  -0.     0.     0.     0.   ]
 [ 1.     0.     0.     0.     0.     0.     0.     0.     0.     0.
   0.     0.     0.     0.   ]
 [ 0.001  0.001  0.004  0.01   0.022  0.041  0.069  0.101  0.129  0.144
   0.142  0.122  0.091  0.124]
 [ 0.014  0.019  0.038  0.064  0.096  0.125  0.143  0.143  0.125  0.096
   0.064  0.038  0.019  0.014]
 [ 0.338  0.093  0.096  0.095  0.09   0.08   0.067  0.052  0.037  0.024
   0.014  0.007  0.004  0.002]]
expected
[[ 1.    -0.     0.     0.     0.    -0.     0.     0.     0.    -0.
   0.     0.    -0.     0.   ]
 [ 1.     0.     0.    -0.     0.     0.     0.    -0.     0.     0.
  -0.     0.     0.     0.   ]
 [ 1.     0.     0.     0.     0.     0.     0.     0.     0.     0.
   0.     0.    -0.    -0.   ]
 [ 0.008  0.01   0.018  0.029  0.043  0.059  0.073  0.085  0.092  0.096
   0.095  0.091  0.082  0.219]
 [ 0.061  0.041  0.056  0.071  0.083  0.092  0.096  0.096  0.092  0.083
   0.071  0.056  0.041  0.061]
 [ 0.338  0.093  0.096  0.095  0.09   0.08   0.067  0.052  0.037  0.024
   0.014  0.007  0.004  0.002]]
difference
[[ 0.     0.     0.     0.     0.     0.     0.     0.     0.     0.
   0.     0.     0.     0.   ]
 [ 0.     0.     0.     0.     0.     0.     0.     0.     0.     0.
   0.     0.     0.     0.   ]
 [ 0.     0.     0.     0.     0.     0.     0.     0.     0.     0.
   0.     0.     0.     0.   ]
 [-0.007 -0.009 -0.014 -0.019 -0.021 -0.018 -0.004  0.016  0.037  0.048
   0.047  0.031  0.009 -0.095]
 [-0.047 -0.022 -0.018 -0.007  0.013  0.033  0.047  0.047  0.033  0.013
  -0.007 -0.018 -0.022 -0.047]
 [ 0.     0.     0.     0.     0.     0.     0.     0.     0.     0.
   0.     0.     0.     0.   ]]
[close]
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 24, 2023, 05:34:31 AM
You shouldn't get the same ones, because the ship is now narrower (it used to be 220 px wide and is now 205 px wide). However, without the empirical test it's a little pointless to work on that code since it could very well be wrong, too. So I've sort of been waiting if you have the chance to create the modded weapon, alternatively you can tell me the right way to go about it and I'll test it.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 26, 2023, 02:47:58 AM
Alright, so, to test this, I edited weapon_data.csv to give the Hephaestus a min and max spread of 180 (rather than 0 and 10). After taking the snapshot below, I also made it fire 25x faster (refire delay 0.01 rather than 0.25). And I edited the Conquest. ship file to give one of the left large guns a 5 degree and the other a 180 degree turret arc.

(https://i.ibb.co/GdwQq0h/image.png) (https://ibb.co/4FyRH7Q)

Here is how it works: Turret arc 5 degrees, weapon spread 180 degrees. The shots are random within 180 degrees:

(https://i.ibb.co/WVxXGjG/image.png) (https://ibb.co/J3BTmgm)

Turret arc 180 degrees, weapon spread 180 degrees, firing at -90 degrees. The shots are random within 180 degrees and go past the turret arc. There is no noticeable "pseudo-tracking" from hypothetical gun rotation during fire, shots appear to be completely random.

(https://i.ibb.co/v1GbyT6/image.png) (https://ibb.co/H7s0wbf)

What this means for the code (and in more general) is: turret arcs are not respected for weapon spread. The weapon shot distribution's mean can align with the endpoint of the turret arc. Will post fixed code shortly.
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on January 26, 2023, 03:08:55 AM
Here is the fixed code. The following changes must be made:
1) The ship object must contain left collision bound, right collision bound and height (in this code: length, but height is actually used in game files) information. Ship width where used should be = right collision bound - left collision bound.
2) The upper bounds code must be changed as we did above.
3) In min max mean code, the calculation is simply "facing + tracking arc / 2, facing -tracking arc / 2" (do still apply the wraparound)

And that's it.
code
Code
#Objective: calculate the optimum angle to place the enemy ship, with regard to our ship
#Definition: angle exactly to the right is 0 degrees, so the ship is heading towards +90 degrees.
#Possible angles are integers, from -179 to 180.
#Assumptions: our ship is pointlike. The enemy ship is at a constant range. The enemy ship's orientation
#is tangential to the circle defined by our ship's position and range. The enemy ship is represented
#by a single line of hittable armor cells.
#Additionally, we assume sufficient range that we do not need to compensate for curvature (that range
#to the enemy ship's cells varies) In short: we assume that it is the case that the secant such that
#the average range is range is approximately the same as the arc and that inaccuracies that result
#from using the average instead of cell-specific range are insignificant.


#Section 0. Test ship and weapons. The relevant parameters for this test are width (ship[5]), left bound(ship[6]), right bound(ship[7]) and length (ship[8])
ship <- c(14000, 500, 10000, 1500, 205, -102, 103, 180, 440, 1.0, 200)

#We will create a data frame containing 18 weapons for testing.
#Weapons are formatted as "name", dps, facing, tracking arc, spread
#first, 6 weapons with easy parameters to parse
weapon1 <- c("right phaser", 7, -10, 20, 5)
weapon2 <- c("left phaser", 7, 10, 20, 5)
weapon3 <- c("pd gun 1", 7, -160, 20, 0)
weapon4 <- c("pd gun 2",7, 180, 20, 0)
weapon5 <- c("pd gun 3",7, 160, 20, 0)
weapon6 <- c("photon torpedo", 7, 120, 90, 5)


#then, 12 weapons with randomly generated data
#the generation process was c(round(100*runif(1)),round(180*runif(1,-1,1)),round(180*(1-log10(runif(1,1,10)))),round(30*runif(1)))
weapon7 <- c("bb gun",5,-150,11,20)
weapon8 <- c("space marine teleporter", 78,69,173,29)
weapon9 <- c("turbolaser", 92,122,111,9)
weapon10 <- c("hex bolter", 24,-136,38,20)
weapon11 <- c("singularity projector", 95,28,122,25)
weapon12 <- c("subspace resonance kazoo", 68,-139,12,2)
weapon13 <- c("left nullspace projector", 10,28,54,0)
weapon14 <- c("telepathic embarrassment generator", 30,-31,35,8)
weapon15 <- c("perfectoid resonance torpedo", 34,72,10,17)
weapon16 <- c("entropy inverter gun",78,-60,13,24)
weapon17 <- c("mini-collapsar rifle", 27,28,16,13)
weapon18 <- c("false vacuum tunneler", 32,78,157,20)

#store all weapons in a data frame - this part should be replaced with however the final integrated program
#handles weapons
no_weapons <- 18
weapons <- data.frame()
for (i in 1:no_weapons) weapons <- rbind(weapons,get(paste("weapon",i,sep="")))
colnames(weapons) <- c("name","damage","facing","trackingrange","spread")

#Section 1. functions of variables

#In this and following sections, say
#1. name of function
#2. input of function
#3. mathematical statement
#4. output of function


#1. G
#2. a real number y
#3. y*Phi(y)+phi(y), where Phi is the cdf and phi is the PDF of a standard normal dist
#4. =#3.
G <- function(y) return(y*pnorm(y) + dnorm(y))

#1. Hit probability of coordinate less than x
#2. A real number z, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. if a > 0 and b > 0, a/2b * ( G(z/a + b/a) - G(z/a - b/a) )
#if a > 0 and b = 0, Phi(z)
#if b > 0 and a = 0, the integral of -infinity to x over a
#function corresponding to a rectangle with base from -b/2 to b/2 of area 1 ie. max(0, min(1, b/2 + z))
#if a = 0 and b = 0, 0 if z < 0 and 1 otherwise
#4. cumulative distribution function of the probability distribution of hits at z (CDF(z))

hit_probability_coord_lessthan_x <- function(z, a, b){
  if(a > 0 & b > 0) return(a/2/b*(G(z/a+b/a)-G(z/a-b/a)))
  if(a > 0 & b == 0) return(pnorm(z,0,a))
  if(a == 0 & b > 0) return(max(0,min(1,b/2+z)))
  if(a == 0 & b == 0) {
    if(z < 0) return(0) else return(1)
  }
}

#1. Degrees to arc
#2. A radius and an angle
#3. 2*pi*r * degrees/360
#4. the arc corresponding to the central angle of the circle defined by radius
deg_to_arc <- function(deg, radius) return(deg*2*radius/360*pi)


#1. Arc to degrees
#2. An arc and a radius
#3. arc / (2*pi*r) * 360
#4. The central angle corresponding to an arc of the circle defined by radius

arc_to_deg <- function(arc, radius) return(arc/2/radius*360/pi)

#1. Min and max mean
#2. A weapon, containing the columns facing, spread and tracking_range at [3],[4],[5]
#3. If spread < tracking_range,
#a vector ( facing + tracking_range/2 - spread / 2, facing - tracking_range/2 + spread / 2 )
#Else the vector ( facing, facing )
#4. Given a weapon, the maximum and minimum means of the probability distribution that it can achieve
#by rotating

min_max_mean <- function(weapon){
    vec <- c(
      weapon[[3]]-weapon[[4]]/2,
      weapon[[3]]+weapon[[4]]/2
    )
    if(vec[1] > 180) vec[1] <- vec[1]-360
    if(vec[2] > 180) vec[2] <- vec[2]-360
    if(vec[1] < -179) vec[1] <- vec[1]+360
    if(vec[2] < -179) vec[2] <- vec[2]+360
    return(vec)
}

#Output the angle between two vectors angled a and b using a dot b = |a||b|cos(angle) and noting these are
#unit vectors



#Given an angle and a weapon, return the mean of the distribution when the weapon tries to track the target
transform_hit_coord <- function(angle, weapon){
  if(weapon$tracking_arc==360) return(angle)
  if(weapon$spread > weapon$tracking_arc) return(weapon$facing)
 
  angle_rad <- angle * pi/180
  facing_rad <- weapon$facing * pi / 180
  angle_to_weapon <- acos(sin(angle_rad)*sin(facing_rad)+cos(angle_rad)*cos(facing_rad))*( 180 / pi )
  if(angle_to_weapon <= (weapon$tracking_arc-weapon$spread)/2) return(angle)
  max_mean_rad <- weapon$max_mean * pi /180
  min_mean_rad <- weapon$min_mean * pi /180
  angle_to_min <- acos(sin(angle_rad)*sin(min_mean_rad)+cos(angle_rad)*cos(min_mean_rad))
  angle_to_max <- acos(sin(angle_rad)*sin(max_mean_rad)+cos(angle_rad)*cos(max_mean_rad))
  if(angle_to_max >= angle_to_min) return(weapon$min_mean)
  return(weapon$max_mean)
}
#1. Generate ship upper bounds
#2. A vector representing a ship, with width stored at [5] and number of cells stored at [6], and a range
#3. algorithm: 1. calculate width of a ship cell, in angles
#              2. the lowest bound is -ship width
#              3. the next bound after any given bound is previous bound + increment
#              4. there are equally many such next bounds as the ship has cells
#                 calculate them successively by applying 3.
#              5. convert the vector to pixels multiplying it by 2*pi*range
#4. A vector with the lower edge of the ship in index 1 and upper bounds of all its armor cells at
#successive indices
generate_ship_upper_bounds <- function(left_collision_bound, right_collision_bound, length){
  #this operation centers the target to a whole pixel
  #we will fire at the center of the ship, so center = 0
  cell_size <- min(30,max(15,length/10))
  ub_vector <- c(0)
  #concatenate positive upper bounds
  while(ub_vector[length(ub_vector)] + cell_size < right_collision_bound) {
    ub_vector <- c(ub_vector, ub_vector[length(ub_vector)]+cell_size)
  }
  ub_vector <- c(ub_vector, right_collision_bound)
  #concatenate negative upper bounds
  while(ub_vector[1] - cell_size > left_collision_bound) ub_vector <- c(ub_vector[1]-cell_size, ub_vector)
  ub_vector <- c(left_collision_bound, ub_vector)
 
  return(ub_vector)
}

#Section 2. functions of functions and variables

#1. Transformed angle
#2. A weapon, containing the columns min_mean and max_mean, and an angle
#3. angle - transform_hit_coord(angle, weapon)
#4. Given that angle is the angle of the target relative to our ship (beta in illustration),
#output is the angle of the target relative to to the weapon (angle alpha in illustration)

transformed_angle <- function(angle, weapon) return(angle-transform_hit_coord(angle,weapon))

#1. Weapon adjustment (pixels)
#2. A weapon and an angle (should be used for the optimum angle)
#3. deg_to_arc(transformed_angle)
#4. Returns the arc from weapon distribution mean to target

weaponadjustment_px <- function(weapon, optimum_angle,range){
  angle_difference <- transformed_angle(optimum_angle,weapon)
  arc <- deg_to_arc(angle_difference,range)
  return(arc)
}


#1. Hit distribution
#2. A vector of upper bounds, a standard deviation a of a normal distribution N(0,a),
#a parameter b of a symmetric uniform distribution (-b/2,b/2)
#3. will not present the detailed calculations for this function, as this section is to be replaced
#by the hit distribution function of the integrated code and is not intended for translation
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds


hit_distribution <- function(upper_bounds, standard_deviation, spread){
  vector <- vector(mode="double", length = length(upper_bounds)+1)
  if (standard_deviation == 0){
    if (spread == 0){
      vector[1] <- 0
      for (j in 2:(length(upper_bounds))) {
        #if both spread and standard deviation are 0 then all shots hit 1 cell. this should be so even if
        #the ship has an even number of cells to prevent ships with even no. cells appearing tougher which is not
        #the case in the real game most likely
        if ((upper_bounds[j] >= 0) & (upper_bounds[j-1] < 0)) vector[j] <- 1
      }
      #return part of a box
    } else {
      vector[1] <- min(1,max(0,(upper_bounds[1]+spread))/(2*spread))
      for (j in 2:(length(upper_bounds))) vector[j] <- min(1,max(0,(upper_bounds[j]+spread))/(2*spread)) - min(1,max(0,(upper_bounds[j-1]+spread))/(2*spread))
      vector[length(upper_bounds)+1] <- 1-min(1,max(0,(upper_bounds[length(upper_bounds)]+spread))/(2*spread))
    }
  } else {
    if (spread != 0){
      vector[1] <- hit_probability_coord_lessthan_x(upper_bounds[1], standard_deviation, spread)
      for (j in 2:(length(upper_bounds))) vector[j] <- (hit_probability_coord_lessthan_x(upper_bounds[j], standard_deviation, spread)-hit_probability_coord_lessthan_x(upper_bounds[j-1], standard_deviation, spread))
      vector[length(upper_bounds)+1] <- (1-hit_probability_coord_lessthan_x(upper_bounds[length(upper_bounds)], standard_deviation, spread))
    } else {
      #if spread is 0 but standard deviation is not 0 we have a normal distribution
      vector[1] <- pnorm(upper_bounds[1],0,standard_deviation)
      for (j in 2:(length(upper_bounds))) vector[j] <- pnorm(upper_bounds[j],0,standard_deviation) - pnorm(upper_bounds[j-1], mean=0, sd=standard_deviation)
      vector[length(upper_bounds)+1] <- 1-pnorm(upper_bounds[length(upper_bounds)], mean=0, sd=standard_deviation)
    }
   
  }
  return(vector)
}

#1. Hit distribution at optimum angle
#2. The optimum angle, the standard error in pixels, a weapon with spread at index 5, a vector of upper bounds
#3. hit distribution for vector of upper bounds + arc from weapon to upper bound
#4. A vector of probability masses, such that cell 1 is chance to hit below lowest upper bound,
#the last cell is chance to hit above highest upper bound, and the others are the probabilities for
#hits between upper bounds, adjusted for ship location

hit_distribution_at_optimum_angle <- function(angle, sd, upper_bounds, weapon, range){
  #convert spread to pixels
  px_spread <- deg_to_arc(weapon[,5], range)
  #adjust upper bound vector
  adj_ubs <- upper_bounds + weaponadjustment_px(weapon, angle,range)
  return(hit_distribution(adj_ubs,sd,px_spread))
}

#1. Summed area under curve
#2. An angle, the standard deviation of the normal distribution in pixels, a set of weapons, and a ship
# s.t. ship width is stored at index 5, weapon damage at index 2 and weapon spread at index 5
#3. Algorithm: convert ship width to degrees and
#              convert standard error to degrees, for consistency in units
#              for each weapon, calculate the angles of the ship's lower bound and upper bound
#              relative to that gun
#              then calculate dps * (CDF of probability distribution of that weapon at upper bound
#              - CDF of probability distribution of that weapon at lower bound)
#4. Output: the sum of expected dps from this set of weapons to target ship at that angle relative to our ship



sum_auc <- function(angle, sd, weapons, ship, range) {
  summed_auc <- 0
  #convert the ship's width from segment to degrees
  shipwidth <- arc_to_deg(ship[5], range)/2
  d_error <- arc_to_deg(sd, range)
 
  for (i in 1:length(weapons[,1])){
    #angle of the ship's upper bound, in coordinates of the distribution mean
    #note that this is weapon specific
    ship_upper_bound <- transformed_angle(angle,weapons[i,])+shipwidth
    ship_lower_bound <- transformed_angle(angle,weapons[i,])-shipwidth
   
    damage <- weapons[i,2]
    spread <- weapons[i,5]
    #now we calculate the sum
    summed_auc <- summed_auc + damage*(
      hit_probability_coord_lessthan_x(ship_upper_bound, d_error, spread) -
        hit_probability_coord_lessthan_x(ship_lower_bound, d_error, spread)
    )
  }
 
  return(summed_auc)
}

#Section 3. functions of functions of functions and variables

#1. main
#2. a ship, a range, a standard deviation, and a list of weapons
#3. described in comments in the function
#4. prints, for each weapon, the probability of a hit at each of the ship's cells, as well as
#of a miss due to hitting below ship's lowest bound in cell 1 and of a miss due to hitting above
#ship's highest bound in the last cell
main <- function(ship, range, sd, weapons){
  # 1. we were given a list of weapons with names etc. so formulate the list with proper types and
  # with room for min and max means
  weapons <- data.frame(name=weapons[,1], damage=as.double(weapons[ ,2]), facing=as.double(weapons[ ,3]),
                        tracking_arc=as.double(weapons[ ,4]),spread=as.double(weapons[ ,5]),min_mean=0,max_mean=0)
  # compute min and max means for weapons
 
  for (i in 1:length(weapons[,1])) {
    weapons[i, 6] <- min_max_mean(weapons[i, ])[1]
    weapons[i, 7] <- min_max_mean(weapons[i, ])[2]
  }
 
  angles <- seq(-179,180)
 
  dps_at_angles <- angles
  for (i in 1:360) {
    dps_at_angles[i] <- sum_auc(dps_at_angles[i], sd, weapons, ship, range)
  }
 
 
  #we use a separate vector to keep track of angle, since vector index 1 corresponds to angle -179 now
  x_axis <- seq(-179,180)
 
  #find the optimum angle by selecting the midmost of those cells that have the highest dps,
  #and from the vector x_axis the angle corresponding to that cell
  #use rounding to avoid errors from numerical math
  optimum_angle <- x_axis[which(round(dps_at_angles,3) == round(max(dps_at_angles),3))
                          [ceiling(length(which(round(dps_at_angles,3) == round(max(dps_at_angles),3)))/2)]]
 
  #calculate ship upper bounds
  upper_bounds <- generate_ship_upper_bounds(ship[6],ship[7],ship[8])
  print("bounds")
  print(upper_bounds)
 
  #calculate and report the distributions for weapons, round for human readability
  print("distributions")
  for (i in 1:length(weapons[,1])){
    print(paste0(weapons[i,1],":"))
    print(round(hit_distribution_at_optimum_angle(optimum_angle,sd,upper_bounds,weapons[i,],range),3))
  }
 
  #testing section - not to be implemented in final code
  #print a graph of the distribution and our choice of angle
  print(weapons)
  plot(dps_at_angles, x=x_axis)
  abline(v=optimum_angle)

  print("optimum angle")
  print(optimum_angle)
}
seq(0,110,18)
#sample runs
main(ship, 1000, 50, weapons[1:6,])
[close]

Output

[1] "bounds"
 [1] -102  -90  -72  -54  -36  -18    0   18   36   54   72   90  103
[1] "distributions"
[1] "right phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "left phaser:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 1:"
 [1] 1 0 0 0 0 0 0 0 0 0 0 0 0 0
[1] "pd gun 2:"
 [1] 0.003 0.003 0.010 0.021 0.040 0.067 0.098 0.125 0.141 0.140 0.122 0.094 0.049 0.087
[1] "pd gun 3:"
 [1] 0.021 0.015 0.039 0.065 0.096 0.124 0.141 0.141 0.124 0.096 0.065 0.039 0.016 0.020
[1] "photon torpedo:"
 [1] 0.253 0.055 0.089 0.094 0.094 0.091 0.083 0.072 0.058 0.043 0.030 0.019 0.008 0.011
            name damage facing tracking_arc spread min_mean max_mean
1   right phaser      7    -10           20      5      -20        0
2    left phaser      7     10           20      5        0       20
3       pd gun 1      7   -160           20      0     -170     -150
4       pd gun 2      7    180           20      0      170     -170
5       pd gun 3      7    160           20      0      150      170
6 photon torpedo      7    120           90      5       75      165
[1] "optimum angle"
[1] 168

(https://i.ibb.co/ZggQC2y/image.png) (https://ibb.co/8xxyVYF)
Title: Re: Optimizing the Conquest: a Mathematical Model of Space Combat
Post by: CapnHector on February 03, 2023, 11:36:19 AM
Hope all is well with you Liral! It seems like we'll be taking a break from this project until you get back, since I don't really have the resources to study Python and all that goes with it just now, and it seems like it would be such a waste to not use the codebase we've built up. But I'll be ready to work on it.

In the meantime I've gotten around to playing some Starsector, so here's a nice photo of some Conquest pack tactics using my new favorite build. I was using Radiants in multi Ordo combats a while but these are actually just better for clearing out most of the enemy fleet. Never should have doubted the Conquest.

(https://i.ibb.co/WsBWxPf/pack-tactics.png) (https://ibb.co/mShCDX4)

(https://i.ibb.co/ZX49X32/image.png) (https://ibb.co/sRx7RhW)