diff --git a/graphs/data/__init__.py b/graphs/data/__init__.py
index b3a8097a5..cb8fad27c 100644
--- a/graphs/data/__init__.py
+++ b/graphs/data/__init__.py
@@ -19,7 +19,7 @@
from . import fitDamageStats
-from . import fitDamageEnvelope
+from . import fitDamageProjection
from . import fitEwarStats
from . import fitRemoteReps
from . import fitShieldRegen
diff --git a/graphs/data/fitDamageEnvelope/getter.py b/graphs/data/fitDamageEnvelope/getter.py
deleted file mode 100644
index a11a0f425..000000000
--- a/graphs/data/fitDamageEnvelope/getter.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# =============================================================================
-# Copyright (C) 2010 Diego Duclos
-#
-# This file is part of pyfa.
-#
-# pyfa is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# pyfa is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with pyfa. If not, see .
-# =============================================================================
-
-
-import eos.config
-from eos.const import FittingHardpoint
-from eos.saveddata.targetProfile import TargetProfile
-from eos.utils.spoolSupport import SpoolOptions, SpoolType
-from graphs.calc import checkLockRange
-from graphs.data.base import SmoothPointGetter
-from graphs.data.fitDamageStats.calc.application import (_calcMissileFactor, _calcTurretChanceToHit, _calcTurretMult,
- getApplicationPerKey, )
-from service.settings import GraphSettings
-
-
-def _buildResistProfile(tgtResists, tgtFullHp):
- if not GraphSettings.getInstance().get('ignoreResists'):
- emRes, thermRes, kinRes, exploRes = tgtResists
- else:
- emRes = thermRes = kinRes = exploRes = 0
- return TargetProfile(emAmount=emRes, thermalAmount=thermRes, kineticAmount=kinRes, explosiveAmount=exploRes,
- hp=tgtFullHp)
-
-
-def _typedDmgScalar(dmgTyped, applicationMult, profile):
- """Apply application multiplier and resist profile, return scalar EHP/s."""
- if applicationMult == 0:
- return 0
- scaled = dmgTyped * applicationMult
- scaled.profile = profile
- return scaled.total
-
-
-def _turretApplication(snapshot, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius):
- cth = _calcTurretChanceToHit(atkSpeed=atkSpeed, atkAngle=atkAngle, atkRadius=src.getRadius(),
- atkOptimalRange=snapshot['maxRange'] or 0, atkFalloffRange=snapshot['falloff'] or 0,
- atkTracking=snapshot['tracking'], atkOptimalSigRadius=snapshot['optimalSigRadius'], distance=distance,
- tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtRadius=tgt.getRadius(), tgtSigRadius=tgtSigRadius)
- return _calcTurretMult(cth)
-
-
-def _missileApplication(snapshot, distance, tgtSpeed, tgtSigRadius):
- rangeData = snapshot['missileMaxRangeData']
- if rangeData is None:
- return 0
- lowerRange, higherRange, higherChance = rangeData
- if distance is None or distance <= lowerRange:
- distanceFactor = 1
- elif lowerRange < distance <= higherRange:
- distanceFactor = higherChance
- else:
- distanceFactor = 0
- if distanceFactor == 0:
- return 0
- applicationFactor = _calcMissileFactor(atkEr=snapshot['aoeCloudSize'], atkEv=snapshot['aoeVelocity'],
- atkDrf=snapshot['aoeDamageReductionFactor'], tgtSpeed=tgtSpeed, tgtSigRadius=tgtSigRadius)
- return distanceFactor * applicationFactor
-
-
-def _snapshotTurret(mod, dmgTyped, charge):
- return {'kind': 'turret', 'charge': charge, 'dmg': dmgTyped, 'maxRange': mod.maxRange, 'falloff': mod.falloff,
- 'tracking': mod.getModifiedItemAttr('trackingSpeed'),
- 'optimalSigRadius': mod.getModifiedItemAttr('optimalSigRadius')}
-
-
-def _snapshotMissile(mod, dmgTyped, charge):
- return {'kind': 'missile', 'charge': charge, 'dmg': dmgTyped, 'missileMaxRangeData': mod.missileMaxRangeData,
- 'aoeCloudSize': mod.getModifiedChargeAttr('aoeCloudSize'),
- 'aoeVelocity': mod.getModifiedChargeAttr('aoeVelocity'),
- 'aoeDamageReductionFactor': mod.getModifiedChargeAttr('aoeDamageReductionFactor'),
- 'isFoF': 'fofMissileLaunching' in (charge.effects if charge else {})}
-
-
-def _isAmmoEnvelopeWeapon(mod):
- """Turret or standard missile launcher with valid charges."""
- if mod.hardpoint not in (FittingHardpoint.TURRET, FittingHardpoint.MISSILE):
- return False
- # Skip exotic weapon groups handled separately by stock app logic
- if mod.item.group.name in ('Missile Launcher Bomb', 'Structure Guided Bomb Launcher'):
- return False
- if 'ChainLightning' in mod.item.effects:
- return False
- if mod.isBreacher:
- return False
- return bool(mod.getValidCharges())
-
-
-def _snapshotForCurrentCharge(mod):
- """Build a snapshot dict for whatever charge is currently loaded on mod."""
- spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
- dmgTyped = mod.getDps(spoolOptions=spoolOptions)
- if mod.hardpoint == FittingHardpoint.TURRET:
- return _snapshotTurret(mod, dmgTyped, mod.charge)
- return _snapshotMissile(mod, dmgTyped, mod.charge)
-
-
-def _collectWeaponCandidates(src):
- """For each ammo-bearing weapon, return list of per-charge snapshots.
-
- Charge-dependent attributes (optimal/falloff/tracking/missile range/AoE) are
- only applied to the module's modified attributes by a full fit recalc.
- Since ammo effects are gun-local in EVE (a crystal in laser-1 does not
- affect laser-2's attributes), we load up to N different ammos onto N
- different weapons of the same group, recalc the fit once, and snapshot
- all N (weapon, ammo) pairs from that single recalc. For a group of size
- K weapons and M ammos this needs ceil(M / K) recalcs instead of M.
- Originals are always restored via try/finally even if a calc raises.
- """
- fit = src.item
- weapon_mods = [mod for mod in fit.activeModulesIter() if _isAmmoEnvelopeWeapon(mod)]
- if not weapon_mods:
- return []
-
- # Group by (item ID, state) — within such a group, snapshots can be shared
- # across mods, and DPS reads need consistent per-mod state.
- groups = {}
- for mod in weapon_mods:
- groups.setdefault((mod.item.ID, mod.state), []).append(mod)
-
- originals = {id(mod): mod.charge for mod in weapon_mods}
- snapshots_by_mod = {id(mod): [] for mod in weapon_mods}
- spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
-
- try:
- for group_mods in groups.values():
- valid_charges = sorted(group_mods[0].getValidCharges(), key=lambda c: c.name)
- if not valid_charges:
- continue
- chunk_size = len(group_mods)
- for chunk_start in range(0, len(valid_charges), chunk_size):
- chunk = valid_charges[chunk_start:chunk_start + chunk_size]
- # Assign one chunk-ammo per group mod (extras stay on their previous charge)
- for i, charge in enumerate(chunk):
- group_mods[i].charge = charge
- fit.clear()
- fit.calculateModifiedAttributes()
- # Snapshot per (assignee mod, charge); copy to all group mods since
- # within an (item ID, state) group attributes for a given ammo match.
- for i, charge in enumerate(chunk):
- assignee = group_mods[i]
- dmgTyped = assignee.getDps(spoolOptions=spoolOptions)
- if dmgTyped.total <= 0:
- continue
- if assignee.hardpoint == FittingHardpoint.TURRET:
- snap = _snapshotTurret(assignee, dmgTyped, charge)
- else:
- snap = _snapshotMissile(assignee, dmgTyped, charge)
- for target_mod in group_mods:
- snapshots_by_mod[id(target_mod)].append(snap)
- finally:
- for mod in weapon_mods:
- mod.charge = originals[id(mod)]
- fit.clear()
- fit.calculateModifiedAttributes()
-
- weapons = [{'mod': mod, 'candidates': snapshots_by_mod[id(mod)]} for mod in weapon_mods if
- snapshots_by_mod[id(mod)]]
- for weapon in weapons:
- weapon['candidates'] = _pruneDominated(weapon['candidates'], src)
- return weapons
-
-
-def _pruneDominated(candidates, src):
- """Drop candidates whose effective-DPS curve is dominated everywhere.
-
- Sample each candidate's application-only multiplier (ignoring resists,
- which are mod-independent and uniformly scale all candidates) over a
- coarse distance grid. A candidate X is dominated if there exists Y such
- that Y's raw_damage * multiplier(distance) >= X's at every sample.
- """
- if len(candidates) <= 1:
- return candidates
- # Sample multipliers under a neutral mid-range scenario; this captures
- # the shape of each ammo's range envelope without depending on misc inputs.
- sampleDistances = [0, 1000, 5000, 10000, 20000, 40000, 80000, 160000, 320000]
- tgtSpeed = 0
- atkSpeed = 0
- tgtSigRadius = 125
- sigRefMod = src.getSigRadius() # not directly used, kept for clarity
- del sigRefMod
- # For each candidate, build a scalar score vector across samples.
- scores = []
- for snap in candidates:
- rawTotal = snap['dmg'].total
- vec = []
- for d in sampleDistances:
- if snap['kind'] == 'turret':
- # Use only the range factor (drop tracking — angular speed is 0 here)
- # by passing 0 atkSpeed/tgtSpeed/tgtAngle.
- mult = _turretApplication(snap, src, src, atkSpeed, 0, d, tgtSpeed, 0, tgtSigRadius)
- else:
- mult = _missileApplication(snap, d, tgtSpeed, tgtSigRadius)
- vec.append(rawTotal * mult)
- scores.append(vec)
- # Mark dominated
- n = len(candidates)
- eps = 1e-9
- keep = [True] * n
- for i in range(n):
- if not keep[i]:
- continue
- for j in range(n):
- if i == j or not keep[j]:
- continue
- # j dominates i if scores[j][k] >= scores[i][k] - eps for all k
- # and scores[j][k] > scores[i][k] + eps for at least one k
- dominates = True
- strict = False
- for k in range(len(sampleDistances)):
- if scores[j][k] + eps < scores[i][k]:
- dominates = False
- break
- if scores[j][k] > scores[i][k] + eps:
- strict = True
- if dominates and strict:
- keep[i] = False
- break
- return [c for c, k in zip(candidates, keep) if k]
-
-
-def _bestWeaponDpsAtDistance(weapon, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius, profile,
- inLockRange):
- if not inLockRange:
- # Special case: FoF missiles ignore lock range
- candidates = [c for c in weapon['candidates'] if c.get('isFoF')]
- if not candidates:
- return 0
- else:
- candidates = weapon['candidates']
- best = 0
- for snap in candidates:
- if snap['kind'] == 'turret':
- mult = _turretApplication(snap, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius)
- else:
- mult = _missileApplication(snap, distance, tgtSpeed, tgtSigRadius)
- scalar = _typedDmgScalar(snap['dmg'], mult, profile)
- if scalar > best:
- best = scalar
- return best
-
-
-class Distance2EnvelopeDpsGetter(SmoothPointGetter):
- _baseResolution = 50
- _extraDepth = 2
-
- def _getCommonData(self, miscParams, src, tgt):
- # Snapshot per-weapon ammo candidates once. _calculatePoint reuses these
- # for every distance step so we avoid repeated charge swaps.
- weapons = _collectWeaponCandidates(src)
- # Track ammo-envelope weapon IDs so we can subtract their stock contribution
- # from the common application map below.
- envelopeMods = {id(w['mod']) for w in weapons}
- # Standard application path covers everything else (drones, fighters,
- # smartbombs, doomsdays, modules without valid charges, etc.).
- defaultSpool = eos.config.settings['globalDefaultSpoolupPercentage']
- spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, defaultSpool, False)
- nonEnvelopeDmg = {}
- for mod in src.item.activeModulesIter():
- if id(mod) in envelopeMods:
- continue
- if not mod.isDealingDamage():
- continue
- nonEnvelopeDmg[mod] = mod.getDps(spoolOptions=spoolOptions)
- for drone in src.item.activeDronesIter():
- if not drone.isDealingDamage():
- continue
- nonEnvelopeDmg[drone] = drone.getDps()
- for fighter in src.item.activeFightersIter():
- if not fighter.isDealingDamage():
- continue
- for effectID, effectDps in fighter.getDpsPerEffect().items():
- nonEnvelopeDmg[(fighter, effectID)] = effectDps
- return {'weapons': weapons, 'nonEnvelopeDmg': nonEnvelopeDmg, 'tgtResists': tgt.getResists(),
- 'tgtFullHp': tgt.getFullHp()}
-
- def _calculatePoint(self, x, miscParams, src, tgt, commonData):
- distance = x
- tgtSpeed = miscParams['tgtSpeed']
- tgtSigRadius = miscParams.get('tgtSigRad', tgt.getSigRadius())
- atkSpeed = miscParams.get('atkSpeed', 0) or 0
- atkAngle = miscParams.get('atkAngle', 0) or 0
- tgtAngle = miscParams.get('tgtAngle', 0) or 0
- profile = _buildResistProfile(commonData['tgtResists'], commonData['tgtFullHp'])
- inLockRange = checkLockRange(src=src, distance=distance)
-
- total = 0
- # Sum optimum-ammo contribution for each ammo-bearing weapon
- for weapon in commonData['weapons']:
- total += _bestWeaponDpsAtDistance(weapon=weapon, src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
- distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius, profile=profile,
- inLockRange=inLockRange)
-
- # Add fixed-ammo contributors (drones, fighters, smartbombs, etc.) using
- # the standard application math from fitDamageStats.
- if commonData['nonEnvelopeDmg']:
- applicationMap = getApplicationPerKey(src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
- distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius)
- for key, dmgTyped in commonData['nonEnvelopeDmg'].items():
- mult = applicationMap.get(key, 0)
- total += _typedDmgScalar(dmgTyped, mult, profile)
- return total
diff --git a/graphs/data/fitDamageEnvelope/graph.py b/graphs/data/fitDamageEnvelope/graph.py
deleted file mode 100644
index a531422fc..000000000
--- a/graphs/data/fitDamageEnvelope/graph.py
+++ /dev/null
@@ -1,72 +0,0 @@
-# =============================================================================
-# Copyright (C) 2010 Diego Duclos
-#
-# This file is part of pyfa.
-#
-# pyfa is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# pyfa is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with pyfa. If not, see .
-# =============================================================================
-
-
-import wx
-
-from graphs.data.base import FitGraph, Input, VectorDef, XDef, YDef
-from service.settings import GraphSettings
-from .getter import Distance2EnvelopeDpsGetter
-
-_t = wx.GetTranslation
-
-
-class FitDamageEnvelopeGraph(FitGraph):
- # UI stuff
- internalName = 'dmgEnvelopeGraph'
- name = _t('Damage Projection')
- xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
- inputs = [
- Input(handle='distance', unit='km', label=_t('Distance'), iconID=1391, defaultValue=None, defaultRange=(0, 100),
- mainTooltip=_t('Distance between the attacker and the target, as seen in overview (surface-to-surface)'),
- secondaryTooltip=_t(
- 'Distance between the attacker and the target, as seen in overview (surface-to-surface)')),
- Input(handle='tgtSpeed', unit='%', label=_t('Target speed'), iconID=1389, defaultValue=100,
- defaultRange=(0, 100)),
- Input(handle='tgtSigRad', unit='%', label=_t('Target signature'), iconID=1390, defaultValue=100,
- defaultRange=(100, 200), conditions=[(('tgtSigRad', 'm'), None), (('tgtSigRad', '%'), None)])]
- srcVectorDef = VectorDef(lengthHandle='atkSpeed', lengthUnit='%', angleHandle='atkAngle', angleUnit='degrees',
- label=_t('Attacker'))
- tgtVectorDef = VectorDef(lengthHandle='tgtSpeed', lengthUnit='%', angleHandle='tgtAngle', angleUnit='degrees',
- label=_t('Target'))
- hasTargets = True
- srcExtraCols = ('Dps', 'Speed', 'Radius')
-
- @property
- def yDefs(self):
- ignoreResists = GraphSettings.getInstance().get('ignoreResists')
- return [YDef(handle='dps', unit=None, label=_t('Best DPS') if ignoreResists else _t('Best effective DPS'))]
-
- @property
- def tgtExtraCols(self):
- cols = []
- if not GraphSettings.getInstance().get('ignoreResists'):
- cols.append('Target Resists')
- cols.extend(('Speed', 'SigRadius', 'Radius', 'FullHP'))
- return cols
-
- # Calculation stuff
- _normalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v * 1000,
- ('atkSpeed', '%'): lambda v, src, tgt: v / 100 * src.getMaxVelocity(),
- ('tgtSpeed', '%'): lambda v, src, tgt: v / 100 * tgt.getMaxVelocity(),
- ('tgtSigRad', '%'): lambda v, src, tgt: v / 100 * tgt.getSigRadius()}
- _getters = {('distance', 'dps'): Distance2EnvelopeDpsGetter}
- _denormalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v / 1000,
- ('tgtSpeed', '%'): lambda v, src, tgt: v * 100 / tgt.getMaxVelocity(),
- ('tgtSigRad', '%'): lambda v, src, tgt: v * 100 / tgt.getSigRadius()}
diff --git a/graphs/data/fitDamageEnvelope/__init__.py b/graphs/data/fitDamageProjection/__init__.py
similarity index 91%
rename from graphs/data/fitDamageEnvelope/__init__.py
rename to graphs/data/fitDamageProjection/__init__.py
index 03378417e..ae21b6975 100644
--- a/graphs/data/fitDamageEnvelope/__init__.py
+++ b/graphs/data/fitDamageProjection/__init__.py
@@ -17,7 +17,7 @@
# along with pyfa. If not, see .
# =============================================================================
+from .graph import FitDamageProjectionGraph
-from .graph import FitDamageEnvelopeGraph
-FitDamageEnvelopeGraph.register()
+FitDamageProjectionGraph.register()
diff --git a/graphs/data/fitDamageProjection/calc/__init__.py b/graphs/data/fitDamageProjection/calc/__init__.py
new file mode 100644
index 000000000..e4ed3e84d
--- /dev/null
+++ b/graphs/data/fitDamageProjection/calc/__init__.py
@@ -0,0 +1,24 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+# Import key functions for convenient access
+from .projected import (
+ buildProjectedCache,
+ getProjectedParamsAtDistance,
+)
diff --git a/graphs/data/fitDamageProjection/calc/charges.py b/graphs/data/fitDamageProjection/calc/charges.py
new file mode 100644
index 000000000..3c3727d3f
--- /dev/null
+++ b/graphs/data/fitDamageProjection/calc/charges.py
@@ -0,0 +1,241 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Navy faction ammo prefixes (for S/M/L ammo)
+NAVY_PREFIXES = (
+ 'Imperial Navy ',
+ 'Republic Fleet ',
+ 'Caldari Navy ',
+ 'Federation Navy ',
+ 'Plasma '
+)
+
+# Capital (XL) "navy-tier" faction ammo prefixes
+# There is no empire Navy XL ammo, so pirate faction serves as the "navy" tier for capitals
+CAPITAL_NAVY_PREFIXES = (
+ 'Sansha ',
+ 'Arch Angel ',
+ 'Shadow ',
+ 'Plasma'
+)
+
+
+# =============================================================================
+# Quality Tier Filtering
+# =============================================================================
+
+def filterChargesByQuality(charges, qualityTier):
+ """
+ Filter charges based on quality tier selection.
+
+ Args:
+ charges: List of charge items
+ qualityTier: 't1', 'navy', or 'all'
+
+ Returns:
+ Filtered list of charges
+
+ Tiers are cumulative:
+ - 't1': Tech I (metaGroup 1) + Tech II (metaGroup 2)
+ - 'navy': t1 + Navy faction ammo (Imperial Navy, Republic Fleet, Caldari Navy, Federation Navy)
+ For XL (capital) ammo: includes pirate faction (Sansha, Arch Angel, Shadow)
+ - 'all': Everything including high-tier faction (Blood, Dark Blood, True Sansha, etc.)
+
+ Tech II ammo is always included as it's a distinct ammo type, not a "better" variant.
+ """
+ if qualityTier == 'all':
+ return charges
+
+ filtered = []
+ for charge in charges:
+ mg = charge.metaGroup
+ mgId = mg.ID if mg else None
+
+ # Tech I (metaGroup 1) - always included
+ if mgId == 1:
+ filtered.append(charge)
+ continue
+
+ # Tech II (metaGroup 2) - always included (distinct ammo type like Conflagration, Void, etc.)
+ if mgId == 2:
+ filtered.append(charge)
+ continue
+
+ # For 'navy' tier, include Navy faction ammo
+ if qualityTier == 'navy' and mgId == 4: # Faction
+ # Check if it's XL (capital) ammo by name suffix
+ isCapital = charge.name.endswith(' XL')
+
+ if isCapital:
+ # For capital ammo, use pirate faction prefixes as "navy" tier
+ if any(charge.name.startswith(prefix) for prefix in CAPITAL_NAVY_PREFIXES):
+ filtered.append(charge)
+ else:
+ # For subcap ammo, use empire Navy prefixes
+ if any(charge.name.startswith(prefix) for prefix in NAVY_PREFIXES):
+ filtered.append(charge)
+
+ return filtered if filtered else charges
+
+
+# =============================================================================
+# Charge Stats Extraction
+# =============================================================================
+
+def getChargeStats(charge):
+ """
+ Extract charge stats including damage values and multipliers.
+
+ Args:
+ charge: The charge item
+
+ Returns:
+ Dict with damage values and range/falloff/tracking multipliers
+ """
+ em = charge.getAttribute('emDamage') or 0
+ thermal = charge.getAttribute('thermalDamage') or 0
+ kinetic = charge.getAttribute('kineticDamage') or 0
+ explosive = charge.getAttribute('explosiveDamage') or 0
+
+ return {
+ 'emDamage': em,
+ 'thermalDamage': thermal,
+ 'kineticDamage': kinetic,
+ 'explosiveDamage': explosive,
+ 'totalDamage': em + thermal + kinetic + explosive,
+ 'rangeMultiplier': charge.getAttribute('weaponRangeMultiplier') or 1,
+ 'falloffMultiplier': charge.getAttribute('fallofMultiplier') or 1,
+ 'trackingMultiplier': charge.getAttribute('trackingSpeedMultiplier') or 1
+ }
+
+
+# =============================================================================
+# Resist Application
+# =============================================================================
+
+def applyResists(chargeStats, tgtResists):
+ """
+ Apply target resists to charge stats.
+
+ Args:
+ chargeStats: Dict from getChargeStats
+ tgtResists: Tuple of (em, therm, kin, explo) resist values (0-1)
+
+ Returns:
+ New dict with resisted damage values
+ """
+ if not tgtResists:
+ return chargeStats
+
+ emRes, thermRes, kinRes, exploRes = tgtResists
+
+ em = chargeStats['emDamage'] * (1 - emRes)
+ thermal = chargeStats['thermalDamage'] * (1 - thermRes)
+ kinetic = chargeStats['kineticDamage'] * (1 - kinRes)
+ explosive = chargeStats['explosiveDamage'] * (1 - exploRes)
+
+ result = chargeStats.copy()
+ result.update({
+ 'emDamage': em,
+ 'thermalDamage': thermal,
+ 'kineticDamage': kinetic,
+ 'explosiveDamage': explosive,
+ 'totalDamage': em + thermal + kinetic + explosive
+ })
+ return result
+
+
+# =============================================================================
+# Charge Data Precomputation
+# =============================================================================
+
+def precomputeChargeData(turretBase, charges, skillMult=1.0, tgtResists=None):
+ """
+ Pre-compute constant values for each charge.
+
+ This computes effective stats (turret base * charge multipliers) and
+ raw volley for each charge, which can then be used for fast lookups.
+
+ Args:
+ turretBase: Base turret stats dict from getTurretBaseStats
+ charges: List of charge items
+ skillMult: Skill damage multiplier from getSkillMultiplier
+ tgtResists: Target resists tuple or None
+
+ Returns:
+ List of dicts with: name, raw_volley, effective_optimal,
+ effective_falloff, effective_tracking
+
+ Note: We do NOT store raw_dps - it's derived from raw_volley / cycle_time
+ when needed at the mixin level.
+ """
+ chargeData = []
+
+ for charge in charges:
+ stats = getChargeStats(charge)
+
+ # Apply resists early for efficiency
+ if tgtResists:
+ stats = applyResists(stats, tgtResists)
+
+ # Compute effective turret stats with charge modifiers
+ effectiveOptimal = turretBase['optimal'] * stats['rangeMultiplier']
+ effectiveFalloff = turretBase['falloff'] * stats['falloffMultiplier']
+ effectiveTracking = turretBase['tracking'] * stats['trackingMultiplier']
+
+ # Compute raw volley (unmodified by range/tracking)
+ rawVolley = stats['totalDamage'] * skillMult * turretBase['damageMultiplier']
+
+ chargeData.append({
+ 'name': charge.name,
+ 'raw_volley': rawVolley,
+ 'effective_optimal': effectiveOptimal,
+ 'effective_falloff': effectiveFalloff,
+ 'effective_tracking': effectiveTracking
+ })
+
+ return chargeData
+
+
+def getLongestRangeMultiplier(charges):
+ """
+ Get the maximum range multiplier from a list of charges.
+
+ Used to calculate the max effective range of a turret for cache sizing.
+
+ Args:
+ charges: List of charge items
+
+ Returns:
+ The highest rangeMultiplier value among all charges
+ """
+ if not charges:
+ return 1.0
+
+ maxRangeMult = 1.0
+ for charge in charges:
+ rangeMult = charge.getAttribute('weaponRangeMultiplier') or 1.0
+ if rangeMult > maxRangeMult:
+ maxRangeMult = rangeMult
+
+ return maxRangeMult
diff --git a/graphs/data/fitDamageProjection/calc/launcher.py b/graphs/data/fitDamageProjection/calc/launcher.py
new file mode 100644
index 000000000..27b38d4fa
--- /dev/null
+++ b/graphs/data/fitDamageProjection/calc/launcher.py
@@ -0,0 +1,697 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import math
+from bisect import bisect_right
+
+from logbook import Logger
+
+from .projected import getProjectedParamsAtDistance
+
+
+pyfalog = Logger(__name__)
+
+
+# =============================================================================
+# Missile Application Factor
+# =============================================================================
+
+def calcMissileFactor(atkEr, atkEv, atkDrf, tgtSpeed, tgtSigRadius):
+ """
+ Calculate missile application factor.
+
+ Formula: min(1, tgtSigRadius/eR, ((eV * tgtSigRadius) / (eR * tgtSpeed))^DRF)
+
+ Args:
+ atkEr: Missile explosion radius (aoeCloudSize) in meters
+ atkEv: Missile explosion velocity (aoeVelocity) in m/s
+ atkDrf: Missile damage reduction factor (aoeDamageReductionFactor)
+ tgtSpeed: Target velocity (m/s)
+ tgtSigRadius: Target signature radius (m)
+
+ Returns:
+ Application factor (0-1)
+ """
+ factors = [1]
+ # "Slow" part - signature vs explosion radius
+ if atkEr > 0:
+ factors.append(tgtSigRadius / atkEr)
+ # "Fast" part - explosion velocity vs target speed (raised to DRF power)
+ if tgtSpeed > 0 and atkEr > 0:
+ factors.append(((atkEv * tgtSigRadius) / (atkEr * tgtSpeed)) ** atkDrf)
+ return min(factors)
+
+
+# =============================================================================
+# Multiplier Extraction
+# =============================================================================
+
+def _extractMultiplier(mod, attr):
+ """
+ Extract multiplier for a specific attribute.
+
+ If the base value is 0 (e.g. Mjolnir has 0 thermal damage), we cannot
+ calculate the multiplier by division (x / 0).
+
+ In that case, we temporarily inject a base value of 1.0 into the modifier
+ dictionary, read the modified value (which will be 1.0 * multiplier),
+ and use that as the multiplier.
+ """
+ base = mod.getChargeBaseAttrValue(attr) or 0
+
+ if base > 0:
+ modified = mod.getModifiedChargeAttr(attr) or 0
+ pyfalog.debug(f"DEBUG: _extractMultiplier({attr}): base={base}, modified={modified}, mult={modified/base}")
+ return modified / base
+
+ # Base is 0, we need to trick the eos logic to give us the multiplier
+ # We use preAssign to set the base value to 1.0 for this calculation
+ pyfalog.debug(f"DEBUG: _extractMultiplier({attr}): base is 0, attempting injection")
+ mod.chargeModifiedAttributes.preAssign(attr, 1.0)
+ try:
+ # Get the modified value, which should now be 1.0 * multiplier
+ multiplier = mod.getModifiedChargeAttr(attr) or 1.0
+ pyfalog.debug(f"DEBUG: _extractMultiplier({attr}): injected base 1.0, got modified={multiplier}")
+ finally:
+ # Cleanup: remove the preAssign
+ # Accessing private members is naughty but eos doesn't give us a clean way to remove preAssigns
+ # and we must clean up to avoid side effects
+ if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__preAssigns:
+ del mod.chargeModifiedAttributes._ModifiedAttributeDict__preAssigns[attr]
+ # Force recalculation by removing from cache
+ if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__modified:
+ del mod.chargeModifiedAttributes._ModifiedAttributeDict__modified[attr]
+ if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__intermediary:
+ del mod.chargeModifiedAttributes._ModifiedAttributeDict__intermediary[attr]
+
+ return multiplier
+
+def getDamageMultipliers(mod):
+ """
+ Extract per-damage-type multipliers by comparing modified to base values.
+
+ This captures all skill bonuses (Warhead Upgrades, etc.) and ship bonuses
+ that affect missile damage. Different damage types may have different bonuses
+ (e.g., Gila has kinetic/thermal bonus).
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Dict with multipliers for emDamage, thermalDamage, kineticDamage, explosiveDamage
+ """
+ if mod.charge is None:
+ return {
+ 'emDamage': 1.0,
+ 'thermalDamage': 1.0,
+ 'kineticDamage': 1.0,
+ 'explosiveDamage': 1.0
+ }
+
+ multipliers = {}
+ for dmgType in ('emDamage', 'thermalDamage', 'kineticDamage', 'explosiveDamage'):
+ multipliers[dmgType] = _extractMultiplier(mod, dmgType)
+
+ return multipliers
+
+
+def getFlightMultipliers(mod):
+ """
+ Extract flight attribute multipliers by comparing modified to base values.
+
+ This captures skill bonuses from Missile Projection, Missile Bombardment,
+ and ship bonuses that affect flight time/velocity.
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Dict with multipliers for maxVelocity and explosionDelay
+ """
+ if mod.charge is None:
+ return {'maxVelocity': 1.0, 'explosionDelay': 1.0}
+
+ multipliers = {}
+ for attr in ('maxVelocity', 'explosionDelay'):
+ multipliers[attr] = _extractMultiplier(mod, attr)
+
+ return multipliers
+
+
+def getApplicationMultipliers(mod):
+ """
+ Extract application attribute multipliers by comparing modified to base values.
+
+ This captures skills like Guided Missile Precision, Target Navigation Prediction,
+ and rigging/implant bonuses that affect explosion radius/velocity.
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Dict with multipliers for aoeCloudSize, aoeVelocity, aoeDamageReductionFactor
+ """
+ if mod.charge is None:
+ return {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0}
+
+ multipliers = {}
+ for attr in ('aoeCloudSize', 'aoeVelocity', 'aoeDamageReductionFactor'):
+ multipliers[attr] = _extractMultiplier(mod, attr)
+
+ return multipliers
+
+
+def getAllMultipliers(mod):
+ """
+ Extract all multipliers (damage, flight, application) from a module.
+
+ Args:
+ mod: Launcher module with a charge loaded
+
+ Returns:
+ Tuple of (damageMults, flightMults, appMults)
+ """
+ # pyfalog.debug(f"DEBUG: getAllMultipliers called for {mod.item.name}, charge={mod.charge}")
+ return (
+ getDamageMultipliers(mod),
+ getFlightMultipliers(mod),
+ getApplicationMultipliers(mod)
+ )
+
+
+# =============================================================================
+# Range Calculation
+# =============================================================================
+
+def calculateMissileRange(maxVelocity, mass, agility, flightTime):
+ """
+ Calculate missile range for a given flight time.
+
+ Uses EVE formula accounting for acceleration time.
+ Source: http://www.eveonline.com/ingameboard.asp?a=topic&threadID=1307419&page=1#15
+
+ D_m = V_m * (T_m + T_0*[exp(- T_m/T_0)-1])
+
+ Simplified: acceleration time = min(flightTime, mass * agility / 1e6)
+
+ Args:
+ maxVelocity: Missile max velocity (m/s)
+ mass: Missile mass (kg)
+ agility: Missile agility
+ flightTime: Flight time (seconds)
+
+ Returns:
+ Range in meters
+ """
+ accelTime = min(flightTime, mass * agility / 1000000)
+ # Average distance during acceleration (starts at 0, ends at maxVelocity)
+ duringAcceleration = maxVelocity / 2 * accelTime
+ # Distance at full speed
+ fullSpeed = maxVelocity * (flightTime - accelTime)
+ return duringAcceleration + fullSpeed
+
+
+def getMissileRangeData(charge, shipRadius, damageMults=None, flightMults=None, appMults=None):
+ """
+ Calculate missile range data for a charge with applied multipliers.
+
+ EVE missiles have discrete flight times - if flight time is 1.3s, there's
+ a 30% chance of flying 2s and 70% chance of flying 1s.
+
+ Args:
+ charge: Missile charge item
+ shipRadius: Launching ship's radius (affects flight time)
+ damageMults: Damage multipliers dict (or None for base values)
+ flightMults: Flight multipliers dict (or None for base values)
+ appMults: Application multipliers dict (or None for base values)
+
+ Returns:
+ Dict with: lowerRange, higherRange, higherChance, maxEffectiveRange,
+ and all computed stats
+ """
+ if flightMults is None:
+ flightMults = {'maxVelocity': 1.0, 'explosionDelay': 1.0}
+ if appMults is None:
+ appMults = {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0}
+ if damageMults is None:
+ damageMults = {'emDamage': 1.0, 'thermalDamage': 1.0, 'kineticDamage': 1.0, 'explosiveDamage': 1.0}
+
+ # Get base charge attributes
+ baseVelocity = charge.getAttribute('maxVelocity') or 0
+ baseExplosionDelay = charge.getAttribute('explosionDelay') or 0
+ baseMass = charge.getAttribute('mass') or 1
+ baseAgility = charge.getAttribute('agility') or 1
+
+ if baseVelocity <= 0 or baseExplosionDelay <= 0:
+ return None
+
+ # Apply flight multipliers
+ maxVelocity = baseVelocity * flightMults['maxVelocity']
+ explosionDelay = baseExplosionDelay * flightMults['explosionDelay']
+
+ # Calculate flight time (includes ship radius bonus)
+ # Flight time has bonus based on ship radius: https://github.com/pyfa-org/Pyfa/issues/2083
+ flightTime = explosionDelay / 1000 + shipRadius / maxVelocity
+
+ # Discrete flight time: floor and ceil
+ lowerTime = math.floor(flightTime)
+ higherTime = math.ceil(flightTime)
+ higherChance = flightTime - lowerTime # Probability of flying the extra second
+
+ # Calculate ranges
+ lowerRange = calculateMissileRange(maxVelocity, baseMass, baseAgility, lowerTime)
+ higherRange = calculateMissileRange(maxVelocity, baseMass, baseAgility, higherTime)
+
+ # Make range center-to-surface (missiles spawn at ship center)
+ lowerRange = max(0, lowerRange - shipRadius)
+ higherRange = max(0, higherRange - shipRadius)
+
+ # Max effective range uses ceil(flightTime) * velocity for sorting
+ maxEffectiveRange = higherRange
+
+ # Get application stats with multipliers
+ baseEr = charge.getAttribute('aoeCloudSize') or 0
+ baseEv = charge.getAttribute('aoeVelocity') or 0
+ baseDrf = charge.getAttribute('aoeDamageReductionFactor') or 1
+
+ explosionRadius = baseEr * appMults['aoeCloudSize']
+ explosionVelocity = baseEv * appMults['aoeVelocity']
+ damageReductionFactor = baseDrf * appMults['aoeDamageReductionFactor']
+
+ # Get damage with multipliers
+ baseEm = charge.getAttribute('emDamage') or 0
+ baseThermal = charge.getAttribute('thermalDamage') or 0
+ baseKinetic = charge.getAttribute('kineticDamage') or 0
+ baseExplosive = charge.getAttribute('explosiveDamage') or 0
+
+ em = baseEm * damageMults['emDamage']
+ thermal = baseThermal * damageMults['thermalDamage']
+ kinetic = baseKinetic * damageMults['kineticDamage']
+ explosive = baseExplosive * damageMults['explosiveDamage']
+ totalDamage = em + thermal + kinetic + explosive
+
+ return {
+ 'lowerRange': lowerRange,
+ 'higherRange': higherRange,
+ 'higherChance': higherChance,
+ 'maxEffectiveRange': maxEffectiveRange,
+ 'explosionRadius': explosionRadius,
+ 'explosionVelocity': explosionVelocity,
+ 'damageReductionFactor': damageReductionFactor,
+ 'totalDamage': totalDamage,
+ 'emDamage': em,
+ 'thermalDamage': thermal,
+ 'kineticDamage': kinetic,
+ 'explosiveDamage': explosive
+ }
+
+
+# =============================================================================
+# Charge Data Precomputation
+# =============================================================================
+
+# Damage type priority for tie-breaking (EM > Thermal > Kinetic > Explosive)
+DAMAGE_TYPE_PRIORITY = {
+ 'em': 0,
+ 'thermal': 1,
+ 'kinetic': 2,
+ 'explosive': 3
+}
+
+
+def getDominantDamageType(chargeName):
+ """
+ Determine the dominant damage type of a missile based on its name.
+
+ Mjolnir = EM, Inferno = Thermal, Scourge = Kinetic, Nova = Explosive
+
+ Args:
+ chargeName: Missile name
+
+ Returns:
+ 'em', 'thermal', 'kinetic', 'explosive', or 'unknown'
+ """
+ nameLower = chargeName.lower()
+ if 'mjolnir' in nameLower:
+ return 'em'
+ elif 'inferno' in nameLower:
+ return 'thermal'
+ elif 'scourge' in nameLower:
+ return 'kinetic'
+ elif 'nova' in nameLower:
+ return 'explosive'
+ return 'unknown'
+
+
+def precomputeMissileChargeData(mod, charges, cycleTimeMs, shipRadius,
+ damageMults=None, flightMults=None, appMults=None,
+ tgtResists=None):
+ """
+ Pre-compute constant values for each missile charge.
+
+ Args:
+ mod: Launcher module
+ charges: List of valid missile charges
+ cycleTimeMs: Launcher cycle time in milliseconds
+ shipRadius: Ship radius for flight calculations
+ damageMults: Per-damage-type multipliers from skills/ship
+ flightMults: Flight attribute multipliers
+ appMults: Application attribute multipliers
+ tgtResists: Target resist tuple (em, therm, kin, explo) or None
+
+ Returns:
+ List of charge data dicts, sorted by maxEffectiveRange descending
+ """
+ if damageMults is None:
+ damageMults = {'emDamage': 1.0, 'thermalDamage': 1.0, 'kineticDamage': 1.0, 'explosiveDamage': 1.0}
+ if flightMults is None:
+ flightMults = {'maxVelocity': 1.0, 'explosionDelay': 1.0}
+ if appMults is None:
+ appMults = {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0}
+
+ # Get launcher damage multiplier
+ launcherDamageMult = mod.getModifiedItemAttr('damageMultiplier') or 1
+
+ chargeData = []
+ for charge in charges:
+ rangeData = getMissileRangeData(charge, shipRadius, damageMults, flightMults, appMults)
+ if rangeData is None:
+ continue
+
+ # Apply target resists
+ totalDamage = rangeData['totalDamage']
+ if tgtResists:
+ emRes, thermRes, kinRes, exploRes = tgtResists
+ totalDamage = (
+ rangeData['emDamage'] * (1 - emRes) +
+ rangeData['thermalDamage'] * (1 - thermRes) +
+ rangeData['kineticDamage'] * (1 - kinRes) +
+ rangeData['explosiveDamage'] * (1 - exploRes)
+ )
+
+ # Calculate raw volley and DPS
+ rawVolley = totalDamage * launcherDamageMult
+ rawDps = rawVolley / (cycleTimeMs / 1000) if cycleTimeMs > 0 else 0
+
+ # Get damage type priority for tie-breaking
+ damageType = getDominantDamageType(charge.name)
+ damagePriority = DAMAGE_TYPE_PRIORITY.get(damageType, 99)
+
+ chargeData.append({
+ 'name': charge.name,
+ 'raw_volley': rawVolley,
+ 'raw_dps': rawDps,
+ 'lowerRange': rangeData['lowerRange'],
+ 'higherRange': rangeData['higherRange'],
+ 'higherChance': rangeData['higherChance'],
+ 'maxEffectiveRange': rangeData['maxEffectiveRange'],
+ 'explosionRadius': rangeData['explosionRadius'],
+ 'explosionVelocity': rangeData['explosionVelocity'],
+ 'damageReductionFactor': rangeData['damageReductionFactor'],
+ 'damage_priority': damagePriority
+ })
+
+ # Sort by maxEffectiveRange descending (longest range first for max range calculation)
+ # Then by raw_dps descending for tie-breaking
+ chargeData.sort(key=lambda x: (-x['maxEffectiveRange'], -x['raw_dps']))
+
+ return chargeData
+
+
+def getMaxEffectiveRange(chargeData):
+ """
+ Get the maximum effective range from precomputed charge data.
+
+ Args:
+ chargeData: List of precomputed charge data dicts
+
+ Returns:
+ Maximum effective range in meters
+ """
+ if not chargeData:
+ return 0
+ # Charge data is sorted by maxEffectiveRange descending
+ return chargeData[0]['maxEffectiveRange']
+
+
+# =============================================================================
+# Applied Volley Calculation
+# =============================================================================
+
+def calculateRangeFactor(distance, lowerRange, higherRange, higherChance):
+ """
+ Calculate range factor for missile at a distance.
+
+ Args:
+ distance: Distance to target (m)
+ lowerRange: Range at floor(flightTime)
+ higherRange: Range at ceil(flightTime)
+ higherChance: Probability of flying the extra second
+
+ Returns:
+ Range factor (0, higherChance, or 1)
+ """
+ if distance <= lowerRange:
+ return 1.0
+ elif distance <= higherRange:
+ return higherChance
+ else:
+ return 0.0
+
+
+def calculateAppliedVolley(chargeData, distance, tgtSpeed, tgtSigRadius):
+ """
+ Calculate applied volley for a missile charge at a distance.
+
+ Args:
+ chargeData: Single charge data dict
+ distance: Distance to target (m)
+ tgtSpeed: Target velocity (m/s) - can be modified by webs
+ tgtSigRadius: Target signature radius (m) - can be modified by TPs
+
+ Returns:
+ Applied volley (damage accounting for range and application)
+ """
+ # Range factor (discrete: 1, higherChance, or 0)
+ rangeFactor = calculateRangeFactor(
+ distance,
+ chargeData['lowerRange'],
+ chargeData['higherRange'],
+ chargeData['higherChance']
+ )
+
+ if rangeFactor == 0:
+ return 0
+
+ # Application factor
+ appFactor = calcMissileFactor(
+ chargeData['explosionRadius'],
+ chargeData['explosionVelocity'],
+ chargeData['damageReductionFactor'],
+ tgtSpeed,
+ tgtSigRadius
+ )
+
+ return chargeData['raw_volley'] * rangeFactor * appFactor
+
+
+def volleyToDps(volley, cycleTimeMs):
+ """
+ Convert volley to DPS.
+
+ Args:
+ volley: Damage per shot
+ cycleTimeMs: Cycle time in milliseconds
+
+ Returns:
+ DPS (damage per second)
+ """
+ if cycleTimeMs <= 0:
+ return 0
+ return volley / (cycleTimeMs / 1000)
+
+
+# =============================================================================
+# Best Charge Finding
+# =============================================================================
+
+def findBestCharge(chargeData, distance, tgtSpeed, tgtSigRadius):
+ """
+ Find the best missile charge at a distance.
+
+ Uses damage type priority (EM > Thermal > Kinetic > Explosive) as tie-breaker.
+
+ Args:
+ chargeData: List of charge data dicts
+ distance: Distance to target (m)
+ tgtSpeed: Target velocity (m/s)
+ tgtSigRadius: Target signature radius (m)
+
+ Returns:
+ Tuple of (best_volley, best_name, best_index)
+ """
+ bestVolley = 0
+ bestName = None
+ bestIndex = 0
+ bestPriority = 99
+
+ for i, cd in enumerate(chargeData):
+ volley = calculateAppliedVolley(cd, distance, tgtSpeed, tgtSigRadius)
+
+ # Tie-break: higher volley wins; if equal, lower damage_priority wins
+ if volley > bestVolley or (volley == bestVolley and volley > 0 and cd['damage_priority'] < bestPriority):
+ bestVolley = volley
+ bestName = cd['name']
+ bestIndex = i
+ bestPriority = cd['damage_priority']
+
+ return bestVolley, bestName, bestIndex
+
+
+# =============================================================================
+# Transition Point Calculation
+# =============================================================================
+
+def _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance):
+ """
+ Update target params using projected cache for webs/TPs.
+
+ Args:
+ baseTgtSpeed: Base target speed (from graph params)
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built cache from buildProjectedCache()
+ distance: Distance in meters
+
+ Returns:
+ Tuple of (tgtSpeed, tgtSigRadius) with projected effects applied
+ """
+ projected = getProjectedParamsAtDistance(projectedCache, distance)
+ return projected['tgtSpeed'], projected['tgtSigRadius']
+
+
+def calculateTransitions(chargeData, baseTgtSpeed, baseTgtSigRadius,
+ projectedCache, maxDistance=300000, resolution=100):
+ """
+ Calculate distances where optimal missile ammo changes.
+
+ Args:
+ chargeData: List of charge data dicts
+ baseTgtSpeed: Base target speed (from graph params)
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built cache for webs/TPs
+ maxDistance: Maximum distance to scan (m)
+ resolution: Distance interval (m)
+
+ Returns:
+ List of tuples: [(distance, charge_index, charge_name, volley), ...]
+ """
+ if not chargeData:
+ return []
+
+ pyfalog.debug(f"[MISSILE] Starting transition calculation with {len(chargeData)} charges")
+ pyfalog.debug(f"[MISSILE] Base params: tgtSpeed={baseTgtSpeed}, tgtSig={baseTgtSigRadius}")
+
+ transitions = []
+ currentCharge = None
+
+ # Start at distance 0
+ tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, 0)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, 0, tgtSpeed, tgtSigRadius)
+ transitions.append((0, bestIdx, bestName, bestVolley))
+ currentCharge = bestName
+
+ # Scan for transitions
+ distance = resolution
+ while distance <= maxDistance:
+ tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, distance, tgtSpeed, tgtSigRadius)
+
+ if bestName != currentCharge:
+ # Binary search for exact transition point
+ low, high = distance - resolution, distance
+ while high - low > 10:
+ mid = (low + high) // 2
+ midSpeed, midSig = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, mid)
+ _, midName, _ = findBestCharge(chargeData, mid, midSpeed, midSig)
+ if midName == currentCharge:
+ low = mid
+ else:
+ high = mid
+
+ # Get volley at transition
+ highSpeed, highSig = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, high)
+ bestVolley, _, _ = findBestCharge(chargeData, high, highSpeed, highSig)
+
+ transitions.append((high, bestIdx, bestName, bestVolley))
+ pyfalog.debug(f"[MISSILE] Transition @ {high/1000:.1f}km: {currentCharge} -> {bestName}")
+ currentCharge = bestName
+
+ # Stop if we're past all missile ranges
+ if bestVolley < 0.01:
+ transitions.append((distance, -1, None, 0))
+ break
+
+ distance += resolution
+
+ pyfalog.debug(f"[MISSILE] Completed: {len(transitions)} transition points found")
+
+ return transitions
+
+
+# =============================================================================
+# Query Functions
+# =============================================================================
+
+def getVolleyAtDistance(transitions, chargeData, distance,
+ baseTgtSpeed, baseTgtSigRadius, projectedCache):
+ """
+ Get applied volley at a specific distance.
+
+ Args:
+ transitions: List of transition tuples
+ chargeData: List of charge data dicts
+ distance: Distance to query (m)
+ baseTgtSpeed: Base target speed
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built projected cache
+
+ Returns:
+ Tuple of (volley, charge_name)
+ """
+ if not transitions or not chargeData:
+ return 0, None
+
+ # Find which charge is optimal at this distance
+ distances = [t[0] for t in transitions]
+ idx = bisect_right(distances, distance) - 1
+ if idx < 0:
+ idx = 0
+
+ chargeIdx = transitions[idx][1]
+ if chargeIdx < 0 or chargeIdx >= len(chargeData):
+ return 0, None
+
+ cd = chargeData[chargeIdx]
+
+ # Calculate exact volley with projected effects
+ tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance)
+ volley = calculateAppliedVolley(cd, distance, tgtSpeed, tgtSigRadius)
+
+ return volley, cd['name']
+
diff --git a/graphs/data/fitDamageProjection/calc/optimize_ammo.py b/graphs/data/fitDamageProjection/calc/optimize_ammo.py
new file mode 100644
index 000000000..0c4c25723
--- /dev/null
+++ b/graphs/data/fitDamageProjection/calc/optimize_ammo.py
@@ -0,0 +1,216 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+from bisect import bisect_right
+
+from logbook import Logger
+
+from .turret import calculateAppliedVolley
+from .projected import getProjectedParamsAtDistance
+
+
+pyfalog = Logger(__name__)
+
+
+# =============================================================================
+# Utility Functions
+# =============================================================================
+
+def volleyToDps(volley, cycleTimeMs):
+ """
+ Convert volley to DPS.
+
+ Args:
+ volley: Damage per shot
+ cycleTimeMs: Cycle time in milliseconds
+
+ Returns:
+ DPS (damage per second)
+ """
+ if cycleTimeMs <= 0:
+ return 0
+ return volley / (cycleTimeMs / 1000)
+
+
+# =============================================================================
+# Best Charge Finding
+# =============================================================================
+
+def findBestCharge(chargeData, distance, turretBase, trackingParams):
+ """
+ Find the best charge at a distance based on applied volley.
+
+ Args:
+ chargeData: List of charge data dicts
+ distance: Surface-to-surface distance (m)
+ turretBase: Base turret stats dict
+ trackingParams: Tracking params dict or None for perfect tracking
+
+ Returns:
+ Tuple of (best_volley, best_name, best_index)
+ """
+ bestVolley = 0
+ bestName = None
+ bestIndex = 0
+
+ for i, cd in enumerate(chargeData):
+ volley = calculateAppliedVolley(cd, distance, turretBase, trackingParams)
+ if volley > bestVolley:
+ bestVolley = volley
+ bestName = cd['name']
+ bestIndex = i
+
+ return bestVolley, bestName, bestIndex
+
+
+# =============================================================================
+# Transition Point Calculation
+# =============================================================================
+
+def _updateTrackingWithCache(baseTrackingParams, projectedCache, distance):
+ """
+ Fast update of tracking params using pre-built projected cache.
+
+ This is the performance-critical inner loop optimization - instead of
+ calling getTackledSpeed/getSigRadiusMult 300+ times, we do a single
+ cache lookup.
+
+ Args:
+ baseTrackingParams: Base tracking params dict (or None for perfect tracking)
+ projectedCache: Cache from buildProjectedCache()
+ distance: Distance (m)
+
+ Returns:
+ Updated tracking params dict with cached tgtSpeed/tgtSigRadius
+ """
+ if baseTrackingParams is None:
+ return None
+
+ params = baseTrackingParams.copy()
+ projected = getProjectedParamsAtDistance(projectedCache, distance)
+ params['tgtSpeed'] = projected['tgtSpeed']
+ params['tgtSigRadius'] = projected['tgtSigRadius']
+ return params
+
+
+def calculateTransitions(chargeData, turretBase, baseTrackingParams,
+ projectedCache,
+ maxDistance=300000, resolution=100):
+ """
+ Calculate distances where optimal ammo changes.
+
+ Uses coarse resolution for scanning, then binary search for exact
+ transition points. This is much faster than fine-grained scanning.
+
+ PERFORMANCE: Uses projectedCache for O(1) lookup of target speed/sig
+ at each distance, avoiding expensive getTackledSpeed/getSigRadiusMult calls.
+
+ Args:
+ chargeData: List of charge data dicts
+ turretBase: Base turret stats dict
+ baseTrackingParams: Base tracking params dict (with base tgtSpeed/tgtSigRadius)
+ projectedCache: Pre-built cache from buildProjectedCache()
+ maxDistance: Maximum distance to scan (m)
+ resolution: Distance interval (m)
+
+ Returns:
+ List of tuples: [(distance, charge_index, charge_name, volley), ...]
+ """
+ if not chargeData:
+ return []
+
+ transitions = []
+ currentCharge = None
+
+ # Start at distance 0
+ params0 = _updateTrackingWithCache(baseTrackingParams, projectedCache, 0)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, 0, turretBase, params0)
+ transitions.append((0, bestIdx, bestName, bestVolley))
+ currentCharge = bestName
+
+ # Scan for transitions
+ distance = resolution
+ while distance <= maxDistance:
+ params = _updateTrackingWithCache(baseTrackingParams, projectedCache, distance)
+ bestVolley, bestName, bestIdx = findBestCharge(chargeData, distance, turretBase, params)
+
+ if bestName != currentCharge:
+ # Binary search for exact transition point
+ low, high = distance - resolution, distance
+ while high - low > 10:
+ mid = (low + high) // 2
+ paramsMid = _updateTrackingWithCache(baseTrackingParams, projectedCache, mid)
+ _, midName, _ = findBestCharge(chargeData, mid, turretBase, paramsMid)
+ if midName == currentCharge:
+ low = mid
+ else:
+ high = mid
+
+ # Get volley at transition
+ paramsHigh = _updateTrackingWithCache(baseTrackingParams, projectedCache, high)
+ bestVolley, _, _ = findBestCharge(chargeData, high, turretBase, paramsHigh)
+
+ transitions.append((high, bestIdx, bestName, bestVolley))
+ currentCharge = bestName
+
+ distance += resolution
+
+ return transitions
+
+
+# =============================================================================
+# Query Functions
+# =============================================================================
+
+def getVolleyAtDistance(transitions, chargeData, turretBase, distance,
+ baseTrackingParams, projectedCache):
+ """
+ Get applied volley at a specific distance.
+
+ Uses transitions for O(log n) charge lookup, then calculates exact volley
+ using the pre-built projected cache for target speed/sig.
+
+ Args:
+ transitions: List of transition tuples from calculateTransitions
+ chargeData: List of charge data dicts
+ turretBase: Base turret stats dict
+ distance: Distance to query (m)
+ baseTrackingParams: Base tracking params dict
+ projectedCache: Pre-built cache from buildProjectedCache()
+
+ Returns:
+ Tuple of (volley, charge_name)
+ """
+ if not transitions:
+ return 0, None
+
+ # Find which charge is optimal at this distance
+ distances = [t[0] for t in transitions]
+ idx = bisect_right(distances, distance) - 1
+ if idx < 0:
+ idx = 0
+
+ chargeIdx = transitions[idx][1]
+ cd = chargeData[chargeIdx]
+
+ # Calculate exact volley with projected effects from cache
+ params = _updateTrackingWithCache(baseTrackingParams, projectedCache, distance)
+ volley = calculateAppliedVolley(cd, distance, turretBase, params)
+
+ return volley, cd['name']
diff --git a/graphs/data/fitDamageProjection/calc/projected.py b/graphs/data/fitDamageProjection/calc/projected.py
new file mode 100644
index 000000000..3a75425d6
--- /dev/null
+++ b/graphs/data/fitDamageProjection/calc/projected.py
@@ -0,0 +1,263 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import math
+from bisect import bisect_right
+
+from eos.calc import calculateRangeFactor
+from eos.utils.float import floatUnerr
+from graphs.calc import checkLockRange, checkDroneControlRange
+from service.const import GraphDpsDroneMode
+from service.settings import GraphSettings
+
+from logbook import Logger
+
+pyfalog = Logger(__name__)
+
+
+# =============================================================================
+# Re-exports from fitDamageStats for convenience
+# =============================================================================
+
+from graphs.data.fitDamageStats.calc.projected import (
+ getScramRange,
+ getScrammables,
+ getTackledSpeed,
+ getSigRadiusMult,
+)
+
+
+# =============================================================================
+# Distance-Keyed Projected Cache
+# =============================================================================
+
+def buildProjectedCache(src, tgt, commonData, baseTgtSpeed, baseTgtSigRadius,
+ maxDistance=300000, resolution=100, existingCache=None):
+ """
+ Build a distance-keyed cache of target speed and signature radius.
+
+ This pre-computes the expensive getTackledSpeed() and getSigRadiusMult()
+ calls at regular intervals, allowing O(1) lookup during ammo optimization.
+
+ If an existingCache is provided and the target hasn't changed (same base
+ speed/sig), we extend it rather than rebuild from scratch.
+
+ Args:
+ src: Source fit wrapper
+ tgt: Target wrapper
+ commonData: Dict with projected effect data (webMods, tpMods, etc.)
+ baseTgtSpeed: Base (untackled) target speed
+ baseTgtSigRadius: Base target signature radius
+ maxDistance: Maximum distance to cache (m)
+ resolution: Distance interval (m)
+ existingCache: Optional existing cache to extend (if target unchanged)
+
+ Returns:
+ Dict with:
+ 'distances': sorted list of distance keys
+ 'cache': {distance: {'tgtSpeed': float, 'tgtSigRadius': float}}
+ 'hasProjected': bool - whether projected effects are applied
+ 'maxCachedDistance': int - highest distance in cache
+ """
+ applyProjected = commonData.get('applyProjected', False)
+
+ # If no projected effects, return a simple cache with base values
+ if not applyProjected:
+ return {
+ 'distances': [],
+ 'cache': {},
+ 'hasProjected': False,
+ 'baseTgtSpeed': baseTgtSpeed,
+ 'baseTgtSigRadius': baseTgtSigRadius,
+ 'maxCachedDistance': 0
+ }
+
+ # Check if we can extend an existing cache
+ # NOTE: Vector angles are now included in the projectedCacheKey (in getter.py)
+ # so this cache is already isolated per vector configuration. We only need to
+ # check if the base target parameters match.
+ canExtend = (
+ existingCache is not None and
+ existingCache.get('hasProjected', False) and
+ existingCache.get('baseTgtSpeed') == baseTgtSpeed and
+ existingCache.get('baseTgtSigRadius') == baseTgtSigRadius
+ )
+
+ if canExtend:
+ existingMax = existingCache.get('maxCachedDistance', 0)
+
+ # If existing cache already covers our needed range, just return it
+ if existingMax >= maxDistance:
+ pyfalog.debug(f"[PROJECTED] Existing cache sufficient: {existingMax/1000:.0f}km >= {maxDistance/1000:.0f}km needed")
+ return existingCache
+
+ # Otherwise, extend the existing cache
+ sigStr = 'inf' if baseTgtSigRadius == float('inf') else f"{baseTgtSigRadius:.1f}m"
+ pyfalog.debug(f"[PROJECTED] Extending cache: {existingMax/1000:.0f}km -> {maxDistance/1000:.0f}km (baseSig={sigStr})")
+ distances = existingCache['distances'].copy()
+ cache = existingCache['cache'].copy()
+ startDistance = existingMax + resolution
+ else:
+ sigStr = 'inf' if baseTgtSigRadius == float('inf') else f"{baseTgtSigRadius:.1f}m"
+ distances = []
+ cache = {}
+ startDistance = 0
+
+ # Extract projected data from commonData
+ srcScramRange = commonData.get('srcScramRange', 0)
+ tgtScrammables = commonData.get('tgtScrammables', ())
+ webMods = commonData.get('webMods', ())
+ webDrones = commonData.get('webDrones', ())
+ webFighters = commonData.get('webFighters', ())
+ tpMods = commonData.get('tpMods', ())
+ tpDrones = commonData.get('tpDrones', ())
+ tpFighters = commonData.get('tpFighters', ())
+
+ # Debug log projected modules
+ if webMods or webDrones or webFighters:
+ pyfalog.debug(f"[PROJECTED] Webs: {len(webMods)} mods, {len(webDrones)} drones, {len(webFighters)} fighters")
+ if tpMods or tpDrones or tpFighters:
+ pyfalog.debug(f"[PROJECTED] TPs: {len(tpMods)} mods, {len(tpDrones)} drones, {len(tpFighters)} fighters")
+
+ distance = startDistance
+ entriesAdded = 0
+ prevSpeed = None
+ while distance <= maxDistance:
+ # Calculate tackled speed at this distance
+ tackledSpeed = getTackledSpeed(
+ src=src,
+ tgt=tgt,
+ currentUntackledSpeed=baseTgtSpeed,
+ srcScramRange=srcScramRange,
+ tgtScrammables=tgtScrammables,
+ webMods=webMods,
+ webDrones=webDrones,
+ webFighters=webFighters,
+ distance=distance
+ )
+
+ # Calculate sig radius multiplier at this distance
+ sigMult = getSigRadiusMult(
+ src=src,
+ tgt=tgt,
+ tgtSpeed=tackledSpeed,
+ srcScramRange=srcScramRange,
+ tgtScrammables=tgtScrammables,
+ tpMods=tpMods,
+ tpDrones=tpDrones,
+ tpFighters=tpFighters,
+ distance=distance
+ )
+
+ # Log significant speed changes (helps debug grapple/web transitions)
+ if prevSpeed is not None and abs(tackledSpeed - prevSpeed) > baseTgtSpeed * 0.05:
+ pyfalog.debug(f"[PROJECTED] Speed change @ {distance/1000:.1f}km: {prevSpeed:.0f} -> {tackledSpeed:.0f} m/s")
+ prevSpeed = tackledSpeed
+
+ distances.append(distance)
+ cache[distance] = {
+ 'tgtSpeed': tackledSpeed,
+ 'tgtSigRadius': baseTgtSigRadius * sigMult
+ }
+
+ distance += resolution
+ entriesAdded += 1
+
+ # Ensure distances list is sorted (should already be, but safe to ensure)
+ distances.sort()
+
+ return {
+ 'distances': distances,
+ 'cache': cache,
+ 'hasProjected': True,
+ 'baseTgtSpeed': baseTgtSpeed,
+ 'baseTgtSigRadius': baseTgtSigRadius,
+ 'maxCachedDistance': distances[-1] if distances else 0
+ }
+
+
+def getProjectedParamsAtDistance(projectedCache, distance, interpolate=True):
+ """
+ Get target speed and sig radius at a distance from the pre-built cache.
+
+ Uses linear interpolation between cache entries for smoother curves,
+ especially important for grapples/webs with falloff mechanics.
+
+ Args:
+ projectedCache: Cache dict from buildProjectedCache()
+ distance: Distance to query (m)
+ interpolate: If True, interpolate between cache entries (default)
+
+ Returns:
+ Dict with 'tgtSpeed' and 'tgtSigRadius'
+ """
+ if not projectedCache.get('hasProjected', False):
+ # No projected effects - return base values
+ return {
+ 'tgtSpeed': projectedCache.get('baseTgtSpeed', 0),
+ 'tgtSigRadius': projectedCache.get('baseTgtSigRadius', 0)
+ }
+
+ distances = projectedCache.get('distances', [])
+ cache = projectedCache.get('cache', {})
+
+ if not distances:
+ return {
+ 'tgtSpeed': projectedCache.get('baseTgtSpeed', 0),
+ 'tgtSigRadius': projectedCache.get('baseTgtSigRadius', 0)
+ }
+
+ # Find position in sorted distances
+ idx = bisect_right(distances, distance) - 1
+
+ # Clamp to valid range
+ if idx < 0:
+ idx = 0
+ if idx >= len(distances) - 1:
+ # At or beyond the last cached distance
+ distKey = distances[-1]
+ return cache[distKey]
+
+ # Get bounding distances
+ distLow = distances[idx]
+ distHigh = distances[idx + 1]
+
+ # If not interpolating or exact match, return lower bound
+ if not interpolate or distance <= distLow:
+ return cache[distLow]
+
+ # Linear interpolation
+ cacheLow = cache[distLow]
+ cacheHigh = cache[distHigh]
+
+ # Calculate interpolation factor (0-1)
+ t = (distance - distLow) / (distHigh - distLow) if distHigh > distLow else 0
+
+ # Interpolate both speed and sig radius
+ # Handle infinity properly - if either value is inf, result should be inf
+ tgtSpeed = cacheLow['tgtSpeed'] + t * (cacheHigh['tgtSpeed'] - cacheLow['tgtSpeed'])
+ if cacheLow['tgtSigRadius'] == float('inf') or cacheHigh['tgtSigRadius'] == float('inf'):
+ tgtSigRadius = float('inf')
+ else:
+ tgtSigRadius = cacheLow['tgtSigRadius'] + t * (cacheHigh['tgtSigRadius'] - cacheLow['tgtSigRadius'])
+
+ return {
+ 'tgtSpeed': tgtSpeed,
+ 'tgtSigRadius': tgtSigRadius
+ }
diff --git a/graphs/data/fitDamageProjection/calc/turret.py b/graphs/data/fitDamageProjection/calc/turret.py
new file mode 100644
index 000000000..e053995e4
--- /dev/null
+++ b/graphs/data/fitDamageProjection/calc/turret.py
@@ -0,0 +1,187 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import math
+
+from eos.calc import calculateRangeFactor
+
+
+# =============================================================================
+# Angular Speed
+# =============================================================================
+
+def calcAngularSpeed(atkSpeed, atkAngle, atkRadius, distance, tgtSpeed, tgtAngle, tgtRadius):
+ """
+ Calculate angular speed (rad/s) between attacker and target.
+ """
+ if distance is None:
+ return 0
+
+ atkAngleRad = atkAngle * math.pi / 180
+ tgtAngleRad = tgtAngle * math.pi / 180
+
+ ctcDistance = atkRadius + distance + tgtRadius
+
+
+ transSpeed = abs(atkSpeed * math.sin(atkAngleRad) - tgtSpeed * math.sin(tgtAngleRad))
+
+ if ctcDistance == 0:
+ return 0 if transSpeed == 0 else math.inf
+ else:
+ return transSpeed / ctcDistance
+
+
+def calcTrackingFactor(tracking, optimalSigRadius, angularSpeed, tgtSigRadius):
+ """
+ Calculate the tracking factor component of chance to hit.
+ """
+ if tracking <= 0 or tgtSigRadius <= 0:
+ return 0
+ if angularSpeed <= 0:
+ return 1.0
+
+ exponent = (angularSpeed * optimalSigRadius) / (tracking * tgtSigRadius)
+ return 0.5 ** (exponent ** 2)
+
+
+# def calcTrackingFactor(atkTracking, atkOptimalSigRadius, angularSpeed, tgtSigRadius):
+# """Calculate tracking chance to hit component."""
+# return 0.5 ** (((angularSpeed * atkOptimalSigRadius) / (atkTracking * tgtSigRadius)) ** 2)
+
+
+def calcTurretDamageMult(chanceToHit):
+ """
+ Calculate turret damage multiplier from chance to hit.
+ """
+ # https://wiki.eveuniversity.org/Turret_mechanics#Damage
+ wreckingChance = min(chanceToHit, 0.01)
+ wreckingPart = wreckingChance * 3
+ normalChance = chanceToHit - wreckingChance
+ if normalChance > 0:
+ avgDamageMult = (0.01 + chanceToHit) / 2 + 0.49
+ normalPart = normalChance * avgDamageMult
+ else:
+ normalPart = 0
+
+ totalMult = normalPart + wreckingPart
+ return totalMult
+
+
+def getTurretBaseStats(mod):
+ """
+ Get turret stats with ship/skill bonuses but WITHOUT charge modifiers.
+ """
+ # Get the modified values (includes charge effects if charge is loaded)
+ optimal = mod.getModifiedItemAttr('maxRange') or 0
+ falloff = mod.getModifiedItemAttr('falloff') or 0
+ tracking = mod.getModifiedItemAttr('trackingSpeed') or 0
+ optimalSigRadius = mod.getModifiedItemAttr('optimalSigRadius') or 0
+ damageMult = mod.getModifiedItemAttr('damageMultiplier') or 1
+
+ # If a charge is loaded, undo its range/falloff/tracking multiplier effects
+ # Charges multiply these stats, so we divide them out to get base stats
+ if mod.charge:
+ chargeRangeMult = mod.charge.getAttribute('weaponRangeMultiplier') or 1
+ chargeFalloffMult = mod.charge.getAttribute('fallofMultiplier') or 1 # EVE typo
+ chargeTrackingMult = mod.charge.getAttribute('trackingSpeedMultiplier') or 1
+
+ if chargeRangeMult != 0:
+ optimal = optimal / chargeRangeMult
+ if chargeFalloffMult != 0:
+ falloff = falloff / chargeFalloffMult
+ if chargeTrackingMult != 0:
+ tracking = tracking / chargeTrackingMult
+
+ return {
+ 'optimal': optimal,
+ 'falloff': falloff,
+ 'tracking': tracking,
+ 'optimalSigRadius': optimalSigRadius,
+ 'damageMultiplier': damageMult
+ }
+
+
+def getSkillMultiplier(mod):
+ """
+ Get the skill-based damage multiplier for a turret.
+ """
+ charge = mod.charge
+ if not charge:
+ return 1.0
+
+ baseDamage = (
+ (charge.getAttribute('emDamage') or 0) +
+ (charge.getAttribute('thermalDamage') or 0) +
+ (charge.getAttribute('kineticDamage') or 0) +
+ (charge.getAttribute('explosiveDamage') or 0)
+ )
+
+ if baseDamage <= 0:
+ return 1.0
+
+ modifiedDamage = (
+ (mod.getModifiedChargeAttr('emDamage') or 0) +
+ (mod.getModifiedChargeAttr('thermalDamage') or 0) +
+ (mod.getModifiedChargeAttr('kineticDamage') or 0) +
+ (mod.getModifiedChargeAttr('explosiveDamage') or 0)
+ )
+
+ return modifiedDamage / baseDamage if baseDamage > 0 else 1.0
+
+
+def calculateAppliedVolley(chargeData, distance, turretBase, trackingParams):
+ """
+ Calculate applied volley for a charge at a distance.
+ """
+ # Range factor
+ if distance <= chargeData['effective_optimal']:
+ rangeFactor = 1.0
+ else:
+ rangeFactor = calculateRangeFactor(
+ chargeData['effective_optimal'],
+ chargeData['effective_falloff'],
+ distance,
+ restrictedRange=False
+ )
+
+ # Tracking factor
+ if trackingParams is None:
+ trackingFactor = 1.0
+ else:
+ angularSpeed = calcAngularSpeed(
+ trackingParams['atkSpeed'],
+ trackingParams['atkAngle'],
+ trackingParams['atkRadius'],
+ distance,
+ trackingParams['tgtSpeed'],
+ trackingParams['tgtAngle'],
+ trackingParams['tgtRadius']
+ )
+ trackingFactor = calcTrackingFactor(
+ chargeData['effective_tracking'],
+ turretBase['optimalSigRadius'],
+ angularSpeed,
+ trackingParams['tgtSigRadius']
+ )
+
+ # Chance to hit and damage multiplier
+ cth = rangeFactor * trackingFactor
+ damageMult = calcTurretDamageMult(cth)
+
+ return chargeData['raw_volley'] * damageMult
diff --git a/graphs/data/fitDamageProjection/calc/valid_charges.py b/graphs/data/fitDamageProjection/calc/valid_charges.py
new file mode 100644
index 000000000..6e03be798
--- /dev/null
+++ b/graphs/data/fitDamageProjection/calc/valid_charges.py
@@ -0,0 +1,79 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import eos.db
+from eos.gamedata import Item
+
+
+# Class-level cache for valid charges: {itemID: set(charges)}
+# This prevents repeated DB queries for the same module type
+_validChargesCache = {}
+
+
+def getValidChargesForModule(module):
+ """
+ Get all valid charges for a module using optimized database query.
+
+ This is a performance-optimized version for graph calculations that:
+ 1. Uses class-level caching to prevent repeated queries for the same module type
+ 2. Uses direct SQLAlchemy queries instead of eager loading full groups
+ 3. Only validates published items that match the charge groups
+
+ Args:
+ module: The Module instance to get valid charges for
+
+ Returns:
+ set: Set of valid Item instances that can be used as charges
+ """
+ # Check class-level cache first
+ if module.item.ID in _validChargesCache:
+ return _validChargesCache[module.item.ID].copy()
+
+ # Collect all charge group IDs for this module
+ chargeGroupIDs = []
+ for i in range(5):
+ itemChargeGroup = module.getModifiedItemAttr('chargeGroup' + str(i), None)
+ if itemChargeGroup:
+ chargeGroupIDs.append(int(itemChargeGroup))
+
+ if not chargeGroupIDs:
+ _validChargesCache[module.item.ID] = set()
+ return set()
+
+ # Query only published items from the relevant charge groups
+ # This is much more efficient than loading entire groups with all attributes
+ session = eos.db.get_gamedata_session()
+
+ # Query published items in the relevant groups
+ # Note: We let attributes lazy-load only when needed by isValidCharge()
+ items = session.query(Item).filter(
+ Item.groupID.in_(chargeGroupIDs),
+ Item.published == True
+ ).all()
+
+ # Validate each item with the module's size/capacity constraints
+ validCharges = set()
+ for item in items:
+ if module.isValidCharge(item):
+ validCharges.add(item)
+
+ # Store in class-level cache
+ _validChargesCache[module.item.ID] = validCharges
+ return validCharges.copy()
+
diff --git a/graphs/data/fitDamageProjection/getter.py b/graphs/data/fitDamageProjection/getter.py
new file mode 100644
index 000000000..d3d1baeca
--- /dev/null
+++ b/graphs/data/fitDamageProjection/getter.py
@@ -0,0 +1,1017 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+from eos.const import FittingHardpoint
+from logbook import Logger
+
+from graphs.data.base.getter import SmoothPointGetter
+from graphs.data.fitDamageStats.calc.projected import (
+ getScramRange, getScrammables
+)
+from service.settings import GraphSettings
+from .calc.valid_charges import getValidChargesForModule
+
+from .calc.turret import (
+ getTurretBaseStats,
+ getSkillMultiplier
+)
+from .calc.charges import (
+ filterChargesByQuality,
+ precomputeChargeData,
+ getLongestRangeMultiplier
+)
+from .calc.optimize_ammo import (
+ volleyToDps,
+ calculateTransitions,
+ getVolleyAtDistance
+)
+from .calc.projected import (
+ buildProjectedCache
+)
+from .calc.launcher import (
+ getAllMultipliers as getLauncherMultipliers,
+ precomputeMissileChargeData,
+ getMaxEffectiveRange as getMissileMaxEffectiveRange,
+ calculateTransitions as calculateMissileTransitions,
+ getVolleyAtDistance as getMissileVolleyAtDistance,
+ volleyToDps as missileVolleyToDps
+)
+
+
+pyfalog = Logger(__name__)
+
+
+# =============================================================================
+# Max Effective Range Calculation
+# =============================================================================
+
+def getMaxEffectiveRange(turretBase, charges):
+ """
+ Calculate the max effective range for a turret with its available charges.
+
+ Formula: optimal * longestRangeMult + falloff * 3.1
+
+ At falloff * 3.1, the range factor is ~0.5% (negligible damage).
+
+ Args:
+ turretBase: Base turret stats dict from getTurretBaseStats
+ charges: List of charge items
+
+ Returns:
+ Max effective range in meters
+ """
+ longestRangeMult = getLongestRangeMultiplier(charges)
+ effectiveOptimal = turretBase['optimal'] * longestRangeMult
+ effectiveMaxRange = effectiveOptimal + turretBase['falloff'] * 3.1
+ return int(effectiveMaxRange)
+
+
+def getTurretRangeInfo(mod, qualityTier, chargeCache=None):
+ """
+ Get turret base stats and max effective range without computing transitions.
+
+ This is used in the first pass to determine how far the projected cache
+ needs to extend.
+
+ Args:
+ mod: The turret module
+ qualityTier: 't1', 'navy', or 'all'
+ chargeCache: Optional cache dict for getValidCharges results
+
+ Returns:
+ Dict with turret_base, charges, max_effective_range, cycle_time_ms
+ Or None if turret has no valid charges
+ """
+ # Get turret base stats
+ turretBase = getTurretBaseStats(mod)
+
+ # Get cycle time
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges - use cache if available
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ # Calculate max effective range
+ maxEffectiveRange = getMaxEffectiveRange(turretBase, charges)
+
+ return {
+ 'turret_base': turretBase,
+ 'charges': charges,
+ 'max_effective_range': maxEffectiveRange,
+ 'cycle_time_ms': cycleTimeMs
+ }
+
+
+# =============================================================================
+# Launcher Max Range Functions
+# =============================================================================
+
+def getLauncherRangeInfo(mod, qualityTier, shipRadius, chargeCache=None):
+ """
+ Get launcher stats and max effective range without computing transitions.
+
+ This is used in the first pass to determine how far the projected cache
+ needs to extend.
+
+ Args:
+ mod: The launcher module
+ qualityTier: 't1', 'navy', or 'all'
+ shipRadius: Ship radius for flight time bonus
+ chargeCache: Optional cache dict for getValidCharges results
+
+ Returns:
+ Dict with charges, max_effective_range, cycle_time_ms, and multipliers
+ Or None if launcher has no valid charges
+ """
+ # Get cycle time
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges - use cache if available
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ # Get multipliers from the currently loaded charge (or first valid charge)
+ damageMults, flightMults, appMults = getLauncherMultipliers(mod)
+
+ # Get launcher damage multiplier
+ launcherDamageMult = mod.getModifiedItemAttr('damageMultiplier') or 1
+
+ # Precompute charge data to determine max effective range
+ chargeData = precomputeMissileChargeData(
+ mod, charges, cycleTimeMs, shipRadius,
+ damageMults, flightMults, appMults,
+ tgtResists=None # Don't filter by resists for range calculation
+ )
+
+ if not chargeData:
+ return None
+
+ # Max effective range is from the longest-range charge
+ maxEffectiveRange = getMissileMaxEffectiveRange(chargeData)
+
+ return {
+ 'charges': charges,
+ 'charge_data': chargeData, # Cache the precomputed data
+ 'max_effective_range': maxEffectiveRange,
+ 'cycle_time_ms': cycleTimeMs,
+ 'damage_mults': damageMults,
+ 'flight_mults': flightMults,
+ 'app_mults': appMults,
+ 'launcher_damage_mult': launcherDamageMult
+ }
+
+
+# =============================================================================
+# Dominant Group Detection
+# =============================================================================
+
+def countWeaponGroups(src):
+ """
+ Count turrets and launchers on the source fit.
+
+ Args:
+ src: Source fit wrapper
+
+ Returns:
+ Tuple of (turret_count, launcher_count)
+ """
+ turretCount = 0
+ launcherCount = 0
+
+ for mod in src.item.activeModulesIter():
+ # Skip mining lasers
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ if mod.hardpoint == FittingHardpoint.TURRET:
+ turretCount += 1
+ elif mod.hardpoint == FittingHardpoint.MISSILE:
+ launcherCount += 1
+
+ return turretCount, launcherCount
+
+
+def getDominantWeaponType(src):
+ """
+ Determine which weapon type dominates on the fit.
+
+ Args:
+ src: Source fit wrapper
+
+ Returns:
+ 'turret', 'launcher', or None (if no weapons)
+ """
+ turretCount, launcherCount = countWeaponGroups(src)
+
+ if turretCount == 0 and launcherCount == 0:
+ return None
+
+ # Turrets win ties (arbitrary, but consistent)
+ if turretCount >= launcherCount:
+ return 'turret'
+ else:
+ return 'launcher'
+
+
+# =============================================================================
+# Cache Building
+# =============================================================================
+
+def buildTurretCacheEntry(mod, qualityTier, tgtResists, baseTrackingParams,
+ projectedCache, chargeCache=None, rangeInfo=None):
+ """
+ Build a complete cache entry for a single turret type.
+
+ Args:
+ mod: The turret module
+ qualityTier: 't1', 'navy', or 'all'
+ tgtResists: Target resists tuple or None
+ baseTrackingParams: Base tracking params dict
+ projectedCache: Pre-built cache from buildProjectedCache()
+ chargeCache: Optional cache dict for getValidCharges results
+ rangeInfo: Optional pre-computed range info from getTurretRangeInfo
+
+ Returns:
+ Dict with charge_data, transitions, turret_base, cycle_time_ms
+ Or None if turret has no valid charges
+ """
+ pyfalog.debug(f"[AMMO] buildTurretCacheEntry START for {mod.item.name}")
+
+ # Use pre-computed range info if available, otherwise compute now
+ if rangeInfo is not None:
+ turretBase = rangeInfo['turret_base']
+ charges = rangeInfo['charges']
+ cycleTimeMs = rangeInfo['cycle_time_ms']
+ else:
+ turretBase = getTurretBaseStats(mod)
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ if not charges:
+ return None
+
+ # Get skill multiplier
+ skillMult = getSkillMultiplier(mod)
+
+ # Precompute charge data
+ chargeData = precomputeChargeData(turretBase, charges, skillMult, tgtResists)
+ pyfalog.debug(f"[AMMO] Precomputed {len(chargeData)} charge data entries")
+
+ # Calculate max effective range for this turret (after charge filtering)
+ # Use the precomputed chargeData to get the longest range
+ maxEffectiveOptimal = max(cd['effective_optimal'] for cd in chargeData)
+ maxEffectiveFalloff = max(cd['effective_falloff'] for cd in chargeData)
+ maxEffectiveRange = int(maxEffectiveOptimal + maxEffectiveFalloff * 3.1)
+ pyfalog.debug(f"[AMMO] Max effective range for this turret: {maxEffectiveRange/1000:.1f}km")
+
+ # Calculate transitions using the pre-built projected cache
+ # Only scan up to this turret's max effective range
+ transitions = calculateTransitions(
+ chargeData, turretBase, baseTrackingParams,
+ projectedCache,
+ maxDistance=maxEffectiveRange
+ )
+
+ pyfalog.debug(f"[AMMO] buildTurretCacheEntry END for {mod.item.name}")
+
+ return {
+ 'charge_data': chargeData,
+ 'transitions': transitions,
+ 'turret_base': turretBase,
+ 'cycle_time_ms': cycleTimeMs,
+ 'count': 1
+ }
+
+
+def buildLauncherCacheEntry(mod, qualityTier, tgtResists, shipRadius,
+ baseTgtSpeed, baseTgtSigRadius,
+ projectedCache, chargeCache=None, rangeInfo=None):
+ """
+ Build a complete cache entry for a single launcher type.
+
+
+ Args:
+ mod: The launcher module
+ qualityTier: 't1', 'navy', or 'all'
+ tgtResists: Target resists tuple or None
+ shipRadius: Ship radius for flight time bonus
+ baseTgtSpeed: Base target speed (from params)
+ baseTgtSigRadius: Base target sig radius
+ projectedCache: Pre-built cache from buildProjectedCache()
+ chargeCache: Optional cache dict for getValidCharges results
+ rangeInfo: Optional pre-computed range info from getLauncherRangeInfo
+
+ Returns:
+ Dict with charge_data, transitions, cycle_time_ms
+ Or None if launcher has no valid charges
+ """
+ pyfalog.debug(f"[AMMO] buildLauncherCacheEntry START for {mod.item.name}")
+
+ # Use pre-computed range info if available, otherwise compute now
+ if rangeInfo is not None:
+ charges = rangeInfo['charges']
+ # chargeData = rangeInfo['charge_data'] # Don't use cached data (it ignores resists)
+ cycleTimeMs = rangeInfo['cycle_time_ms']
+ damageMults = rangeInfo['damage_mults']
+ flightMults = rangeInfo['flight_mults']
+ appMults = rangeInfo['app_mults']
+ else:
+ cycleParams = mod.getCycleParameters()
+ if cycleParams is None:
+ return None
+ cycleTimeMs = cycleParams.averageTime
+
+ # Get and filter charges
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ if chargeCache is not None and chargeCacheKey in chargeCache:
+ charges = chargeCache[chargeCacheKey]
+ else:
+ allCharges = list(getValidChargesForModule(mod))
+ charges = filterChargesByQuality(allCharges, qualityTier)
+ if chargeCache is not None:
+ chargeCache[chargeCacheKey] = charges
+
+ if not charges:
+ return None
+
+ # Get multipliers from the currently loaded charge
+ damageMults, flightMults, appMults = getLauncherMultipliers(mod)
+
+ # Precompute charge data with current resists
+ chargeData = precomputeMissileChargeData(
+ mod, charges, cycleTimeMs, shipRadius,
+ damageMults, flightMults, appMults, tgtResists
+ )
+
+ if not chargeData:
+ return None
+
+ pyfalog.debug(f"[AMMO] Precomputed {len(chargeData)} missile charge data entries")
+
+ # Calculate max effective range from precomputed data
+ maxEffectiveRange = getMissileMaxEffectiveRange(chargeData)
+ pyfalog.debug(f"[AMMO] Max effective range for this launcher: {maxEffectiveRange/1000:.1f}km")
+
+ # Calculate transitions using the pre-built projected cache
+ transitions = calculateMissileTransitions(
+ chargeData, baseTgtSpeed, baseTgtSigRadius,
+ projectedCache,
+ maxDistance=int(maxEffectiveRange)
+ )
+
+ pyfalog.debug(f"[AMMO] buildLauncherCacheEntry END for {mod.item.name}")
+
+ return {
+ 'charge_data': chargeData,
+ 'transitions': transitions,
+ 'cycle_time_ms': cycleTimeMs,
+ 'count': 1
+ }
+
+
+# =============================================================================
+# Y-Axis Mixins
+# =============================================================================
+
+class YOptimalAmmoDpsMixin:
+ """Y-axis mixin: Calculate DPS using optimal ammo selection."""
+
+ def _getOptimalDpsAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total DPS with optimal ammo at a specific distance."""
+ totalDps = 0
+
+ if distance == 0: # Log details at distance 0 for debugging
+ pyfalog.debug(f"[DPS-CALC] weaponType={weaponType}, weaponCache has {len(weaponCache)} groups")
+ pyfalog.debug(f"[DPS-CALC] trackingParams={trackingParams}")
+ pyfalog.debug(f"[DPS-CALC] projectedCache has {len(projectedCache)} entries")
+
+ if weaponType == 'turret':
+ for group_id, groupInfo in weaponCache.items():
+ if distance == 0:
+ pyfalog.debug(f"[DPS-CALC] Turret group {group_id}: {len(groupInfo.get('transitions', []))} transitions, {len(groupInfo.get('charge_data', []))} charges")
+ volley, _ = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ if distance == 0:
+ pyfalog.debug(f"[DPS-CALC] Turret volley at {distance}m = {volley}")
+ dps = volleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+ else: # launcher
+ for group_id, groupInfo in weaponCache.items():
+ if distance == 0:
+ pyfalog.debug(f"[DPS-CALC] Launcher group {group_id}: {len(groupInfo.get('transitions', []))} transitions, {len(groupInfo.get('charge_data', []))} charges")
+ volley, _ = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ trackingParams['tgtSpeed'],
+ trackingParams['tgtSigRadius'],
+ projectedCache
+ )
+ if distance == 0:
+ pyfalog.debug(f"[DPS-CALC] Launcher volley at {distance}m = {volley}")
+ dps = missileVolleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+
+ if distance == 0:
+ pyfalog.debug(f"[DPS-CALC] Total DPS at {distance}m = {totalDps}")
+
+ return totalDps
+
+ def _getOptimalDpsWithAmmoAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total DPS and ammo name at a specific distance."""
+ totalDps = 0
+ ammoName = None
+
+ if weaponType == 'turret':
+ for groupInfo in weaponCache.values():
+ volley, name = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ dps = volleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+ else: # launcher
+ for groupInfo in weaponCache.values():
+ volley, name = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ trackingParams['tgtSpeed'],
+ trackingParams['tgtSigRadius'],
+ projectedCache
+ )
+ dps = missileVolleyToDps(volley, groupInfo['cycle_time_ms'])
+ totalDps += dps * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+
+ return totalDps, ammoName
+
+
+class YOptimalAmmoVolleyMixin:
+ """Y-axis mixin: Calculate volley using optimal ammo selection."""
+
+ def _getOptimalVolleyAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total volley with optimal ammo at a specific distance."""
+ totalVolley = 0
+
+ if weaponType == 'turret':
+ for groupInfo in weaponCache.values():
+ volley, _ = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+ else: # launcher
+ for groupInfo in weaponCache.values():
+ volley, _ = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ trackingParams['tgtSpeed'],
+ trackingParams['tgtSigRadius'],
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+
+ return totalVolley
+
+ def _getOptimalVolleyWithAmmoAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType):
+ """Get total volley and ammo name at a specific distance."""
+ totalVolley = 0
+ ammoName = None
+
+ if weaponType == 'turret':
+ for groupInfo in weaponCache.values():
+ volley, name = getVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ groupInfo['turret_base'],
+ distance,
+ trackingParams,
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+ else: # launcher
+ for groupInfo in weaponCache.values():
+ volley, name = getMissileVolleyAtDistance(
+ groupInfo['transitions'],
+ groupInfo['charge_data'],
+ distance,
+ trackingParams['tgtSpeed'],
+ trackingParams['tgtSigRadius'],
+ projectedCache
+ )
+ totalVolley += volley * groupInfo['count']
+ if ammoName is None:
+ ammoName = name
+
+ return totalVolley, ammoName
+
+
+# =============================================================================
+# X-Axis Mixin
+# =============================================================================
+
+class XDistanceMixin(SmoothPointGetter):
+ """X-axis mixin: Distance in meters. Builds weapon cache and handles lookups."""
+
+ # Coarse resolution for graph display - 100m intervals
+ # Exact calculations are done on-demand via getPoint/getPointExtended
+ _baseResolution = 100 # meters
+
+ def _getCommonData(self, miscParams, src, tgt):
+ """
+ Build common data including projected cache and weapon (turret/launcher) cache.
+
+ The projected cache is keyed by target (tgtSpeed, tgtSigRadius) and can be
+ extended if the attacker's max range increases, without recalculating
+ existing entries.
+ """
+ # Get settings
+ qualityTier = getattr(self.graph, '_ammoQuality', 'all')
+ ignoreResists = GraphSettings.getInstance().get('ignoreResists')
+ applyProjected = GraphSettings.getInstance().get('applyProjected')
+
+ tgtResists = None if (ignoreResists or tgt is None) else tgt.getResists()
+ tgtSpeed = miscParams.get('tgtSpeed', 0) or 0
+ tgtSigRadius = tgt.getSigRadius() if tgt else 0
+ shipRadius = src.getRadius()
+
+ weaponType = getDominantWeaponType(src)
+
+ fit_id = src.item.ID
+
+ atkSpeed = miscParams.get('atkSpeed', 0) or 0
+ atkAngle = miscParams.get('atkAngle', 0) or 0
+ tgtAngle = miscParams.get('tgtAngle', 0) or 0
+
+ weaponCacheKey = (fit_id, weaponType, qualityTier, tgtResists, applyProjected, tgtSpeed, tgtSigRadius, atkSpeed, atkAngle, tgtAngle)
+
+ projectedCacheKey = (fit_id, tgtSpeed, tgtSigRadius, atkSpeed, atkAngle, tgtAngle)
+
+ # Initialize graph caches if needed
+ if not hasattr(self.graph, '_ammo_weapon_cache'):
+ self.graph._ammo_weapon_cache = {}
+ if not hasattr(self.graph, '_ammo_charge_cache'):
+ self.graph._ammo_charge_cache = {}
+ if not hasattr(self.graph, '_ammo_projected_cache'):
+ self.graph._ammo_projected_cache = {}
+
+ # Build base commonData with projected effect info
+ commonData = {
+ 'applyProjected': applyProjected,
+ 'src_radius': shipRadius,
+ 'weapon_type': weaponType,
+ }
+
+ # Add projected effect data if enabled
+ if applyProjected:
+ commonData['srcScramRange'] = getScramRange(src=src)
+ commonData['tgtScrammables'] = getScrammables(tgt=tgt) if tgt else ()
+ webMods, tpMods = self.graph._projectedCache.getProjModData(src)
+ webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src)
+ webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src)
+ commonData['webMods'] = webMods
+ commonData['tpMods'] = tpMods
+ commonData['webDrones'] = webDrones
+ commonData['tpDrones'] = tpDrones
+ commonData['webFighters'] = webFighters
+ commonData['tpFighters'] = tpFighters
+
+ if weaponCacheKey in self.graph._ammo_weapon_cache:
+ cached_weapon = self.graph._ammo_weapon_cache[weaponCacheKey]
+ commonData['weapon_cache'] = cached_weapon
+ commonData['projected_cache'] = self.graph._ammo_projected_cache.get(projectedCacheKey, {})
+ return commonData
+
+ if weaponType is None:
+ commonData['weapon_cache'] = {}
+ commonData['projected_cache'] = {}
+ return commonData
+
+
+ weaponRangeInfos = {} # {mod.item.ID: rangeInfo}
+ maxEffectiveRange = 0
+
+ if weaponType == 'turret':
+ hardpointType = FittingHardpoint.TURRET
+ else:
+ hardpointType = FittingHardpoint.MISSILE
+
+ for mod in src.item.activeModulesIter():
+ # pyfalog.debug(f"DEBUG: Processing module {mod.item.name}, hardpoint={mod.hardpoint}, charge={mod.charge}")
+ if mod.hardpoint != hardpointType:
+ continue
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ key = mod.item.ID
+ if key not in weaponRangeInfos:
+ if weaponType == 'turret':
+ rangeInfo = getTurretRangeInfo(mod, qualityTier, self.graph._ammo_charge_cache)
+ else:
+ # Special handling for empty launchers (Missiles only):
+ # To apply skill/ship modifiers correctly, eos needs a charge loaded.
+ # If launcher is empty, temporarily load a charge to extract multipliers.
+ if mod.charge is None:
+ # Find a valid charge to simulate load
+ chargeCacheKey = (mod.item.ID, qualityTier)
+ validCharges = None
+ if self.graph._ammo_charge_cache is not None and chargeCacheKey in self.graph._ammo_charge_cache:
+ validCharges = self.graph._ammo_charge_cache[chargeCacheKey]
+
+ if validCharges is None:
+ allCharges = list(getValidChargesForModule(mod))
+ validCharges = filterChargesByQuality(allCharges, qualityTier)
+ if self.graph._ammo_charge_cache is not None:
+ self.graph._ammo_charge_cache[chargeCacheKey] = validCharges
+
+ if validCharges:
+ # Temporarily load the first valid charge
+ tempCharge = validCharges[0]
+ try:
+ # pyfalog.debug(f"DEBUG: Temporarily loading {tempCharge.name} into {mod.item.name} for modifier extraction")
+ mod.charge = tempCharge
+ # Force fit update (important for effects to apply)
+ if mod.owner:
+ # pyfalog.debug("DEBUG: Forcing fit recalculation (1)")
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+
+ # Extract multipliers (optional debug)
+ # damageMults, flightMults, appMults = getLauncherMultipliers(mod)
+ # pyfalog.debug(f"DEBUG: Extracted multipliers: Dmg={damageMults}, Flt={flightMults}, App={appMults}")
+
+ # pyfalog.debug("DEBUG: calling getLauncherRangeInfo with temp charge loaded")
+ ranges = getLauncherRangeInfo(mod, qualityTier, shipRadius, self.graph._ammo_charge_cache)
+ # p_dmults, p_fmults, p_amults = getLauncherMultipliers(mod)
+ # pyfalog.debug(f"DEBUG: Multipliers during range calc: Dmg={p_dmults}")
+ rangeInfo = ranges
+
+ # Unload charge
+ mod.charge = None
+ if mod.owner:
+ # pyfalog.debug("DEBUG: Forcing fit recalculation (Cleanup)")
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+ # pyfalog.debug("DEBUG: Charge unloaded")
+
+ except Exception as e:
+ pyfalog.error(f"Error simulating charge for {mod.item.name}: {e}")
+ mod.charge = None # Ensure cleanup
+ if mod.owner:
+ mod.owner.calculated = False
+ try:
+ mod.owner.calculateModifiedAttributes()
+ except:
+ pass
+ rangeInfo = None
+ else:
+ rangeInfo = None
+ else:
+ rangeInfo = getLauncherRangeInfo(mod, qualityTier, shipRadius, self.graph._ammo_charge_cache)
+
+ if rangeInfo:
+ weaponRangeInfos[key] = rangeInfo
+ if rangeInfo['max_effective_range'] > maxEffectiveRange:
+ maxEffectiveRange = rangeInfo['max_effective_range']
+
+ if not weaponRangeInfos:
+ # No weapons found
+ commonData['weapon_cache'] = {}
+ commonData['projected_cache'] = {}
+ return commonData
+
+ # =====================================================================
+ # PHASE 2: Build/extend projected cache to max effective range
+ # =====================================================================
+
+ # Get existing cache for this target (if any)
+ existingCache = self.graph._ammo_projected_cache.get(projectedCacheKey)
+
+ # Build base tracking params (used for turrets, also provides tgtSpeed/tgtSig for missiles)
+ # Vector parameters already extracted above for cache keys
+ baseTrackingParams = {
+ 'atkSpeed': atkSpeed,
+ 'atkAngle': atkAngle,
+ 'atkRadius': shipRadius,
+ 'tgtSpeed': tgtSpeed,
+ 'tgtAngle': tgtAngle,
+ 'tgtRadius': tgt.getRadius() if tgt else 0,
+ 'tgtSigRadius': tgtSigRadius
+ }
+
+ # Build or extend the projected cache
+ projectedCache = buildProjectedCache(
+ src=src,
+ tgt=tgt,
+ commonData=commonData,
+ baseTgtSpeed=tgtSpeed,
+ baseTgtSigRadius=tgtSigRadius,
+ maxDistance=maxEffectiveRange,
+ resolution=100, # 100m intervals
+ existingCache=existingCache
+ )
+
+ # Store projected cache - can be reused if target stays the same
+ self.graph._ammo_projected_cache[projectedCacheKey] = projectedCache
+ commonData['projected_cache'] = projectedCache
+
+ # =====================================================================
+ # PHASE 3: Build weapon cache with transitions
+ # =====================================================================
+
+ weaponCache = {}
+ for mod in src.item.activeModulesIter():
+ if mod.hardpoint != hardpointType:
+ continue
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ key = mod.item.ID
+ if key not in weaponCache:
+ rangeInfo = weaponRangeInfos.get(key)
+ if rangeInfo:
+ if weaponType == 'turret':
+ entry = buildTurretCacheEntry(
+ mod, qualityTier, tgtResists, baseTrackingParams,
+ projectedCache, self.graph._ammo_charge_cache,
+ rangeInfo=rangeInfo
+ )
+ else:
+ entry = buildLauncherCacheEntry(
+ mod, qualityTier, tgtResists, shipRadius,
+ tgtSpeed, tgtSigRadius,
+ projectedCache, self.graph._ammo_charge_cache,
+ rangeInfo=rangeInfo
+ )
+ if entry:
+ weaponCache[key] = entry
+ else:
+ weaponCache[key]['count'] += 1
+
+ # Cache and return
+ self.graph._ammo_weapon_cache[weaponCacheKey] = weaponCache
+ commonData['weapon_cache'] = weaponCache
+
+ return commonData
+
+ def _buildTrackingParams(self, distance, miscParams, src, tgt, commonData):
+ """
+ Build base tracking params for a distance query.
+
+ NOTE: This returns BASE params only. The projected effects (web/TP)
+ are applied via the projected cache in getVolleyAtDistance.
+ """
+ tgtSpeed = miscParams.get('tgtSpeed', 0) or 0
+ tgtSigRadius = tgt.getSigRadius() if tgt else 0
+
+ if distance == 0: # Debug logging at distance 0
+ sigStr = 'inf' if tgtSigRadius == float('inf') else f"{tgtSigRadius:.1f}"
+ pyfalog.debug(f"[TRACKING] Building tracking params: tgtSpeed={tgtSpeed:.1f}, tgtSigRadius={sigStr}")
+ pyfalog.debug(f"[TRACKING] tgt={tgt.name if tgt else None}")
+
+ # Only return None if sig radius is exactly 0 (not infinity - that's valid for Ideal Target)
+ if tgtSigRadius == 0:
+ pyfalog.debug(f"[TRACKING] tgtSigRadius is 0, returning None!")
+ return None
+
+ params = {
+ 'atkSpeed': miscParams.get('atkSpeed', 0) or 0,
+ 'atkAngle': miscParams.get('atkAngle', 0) or 0,
+ 'atkRadius': commonData.get('src_radius', 0),
+ 'tgtSpeed': tgtSpeed,
+ 'tgtAngle': miscParams.get('tgtAngle', 0) or 0,
+ 'tgtRadius': tgt.getRadius() if tgt else 0,
+ 'tgtSigRadius': tgtSigRadius
+ }
+
+ if distance == 0:
+ pyfalog.debug(f"[TRACKING] Returning params: {params}")
+
+ return params
+
+ def _calculatePoint(self, x, miscParams, src, tgt, commonData):
+ """Calculate value at distance x."""
+ weaponCache = commonData.get('weapon_cache', {})
+ weaponType = commonData.get('weapon_type')
+ if not weaponCache:
+ pyfalog.debug(f"[CALC-POINT] No weaponCache for {src.item.name} at distance {x/1000:.1f}km, returning 0")
+ return 0
+
+ trackingParams = self._buildTrackingParams(x, miscParams, src, tgt, commonData)
+ projectedCache = commonData.get('projected_cache', {})
+
+ if hasattr(self, '_getOptimalDpsAtDistance'):
+ result = self._getOptimalDpsAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ if x % 10000 == 0: # Log every 10km for sampling
+ pyfalog.debug(f"[CALC-POINT] {src.item.name} at {x/1000:.1f}km: DPS={result:.1f}")
+ return result
+ elif hasattr(self, '_getOptimalVolleyAtDistance'):
+ result = self._getOptimalVolleyAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ if x % 10000 == 0: # Log every 10km for sampling
+ pyfalog.debug(f"[CALC-POINT] {src.item.name} at {x/1000:.1f}km: Volley={result:.1f}")
+ return result
+ return 0
+
+ def _calculatePointExtended(self, x, miscParams, src, tgt, commonData):
+ """Calculate value and ammo name at distance x."""
+ weaponCache = commonData.get('weapon_cache', {})
+ weaponType = commonData.get('weapon_type')
+ if not weaponCache:
+ return 0, None
+
+ trackingParams = self._buildTrackingParams(x, miscParams, src, tgt, commonData)
+ projectedCache = commonData.get('projected_cache', {})
+
+ if hasattr(self, '_getOptimalDpsWithAmmoAtDistance'):
+ return self._getOptimalDpsWithAmmoAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ elif hasattr(self, '_getOptimalVolleyWithAmmoAtDistance'):
+ return self._getOptimalVolleyWithAmmoAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType)
+ return 0, None
+
+ def getSegments(self, xRange, miscParams, src, tgt):
+ """Get plot segments with ammo transition information."""
+ pyfalog.debug(f"[SEGMENTS] ========== getSegments START for src={src.item.name}, tgt={tgt.name if tgt else None} ==========")
+ pyfalog.debug(f"[SEGMENTS] xRange={xRange}")
+ # Validate xRange - can contain None from range limiters
+ minX, maxX = xRange
+ if minX is None or maxX is None:
+ pyfalog.debug(f"[SEGMENTS] Returning empty - xRange contains None: minX={minX}, maxX={maxX}")
+ return []
+
+ pyfalog.debug(f"[SEGMENTS] Calling _getCommonData for {src.item.name}...")
+ commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt)
+ weaponCache = commonData.get('weapon_cache', {})
+ weaponType = commonData.get('weapon_type')
+ pyfalog.debug(f"[SEGMENTS] After _getCommonData: weaponType={weaponType}, weaponCache has {len(weaponCache)} groups")
+ pyfalog.debug(f"[SEGMENTS] weaponCache id: {id(weaponCache)}")
+
+ if not weaponCache:
+ pyfalog.debug(f"[SEGMENTS] Returning empty - no weaponCache")
+ return []
+
+ # Get transitions from first weapon group
+ transitions = None
+ for groupInfo in weaponCache.values():
+ transitions = groupInfo['transitions']
+ pyfalog.debug(f"[SEGMENTS] Got {len(transitions) if transitions else 0} transitions from first weapon group")
+ break
+
+ if not transitions:
+ pyfalog.debug(f"[SEGMENTS] Returning empty - no transitions")
+ return []
+
+ # Filter valid transitions (with ammo name)
+ validTransitions = [t for t in transitions if t[2] is not None]
+ pyfalog.debug(f"[SEGMENTS] {len(validTransitions)} valid transitions (with ammo name)")
+ if not validTransitions:
+ pyfalog.debug(f"[SEGMENTS] Returning empty - no valid transitions")
+ return []
+
+ # Build ammo index mapping
+ ammoToIndex = {}
+ for t in validTransitions:
+ if t[2] not in ammoToIndex:
+ ammoToIndex[t[2]] = len(ammoToIndex)
+
+ # Generate segments
+ segments = []
+
+ for i, transition in enumerate(validTransitions):
+ transDist, _, ammoName, _ = transition
+ segStart = max(transDist, minX)
+
+ # Find segment end
+ if i + 1 < len(validTransitions):
+ segEnd = min(validTransitions[i + 1][0], maxX)
+ else:
+ segEnd = maxX
+
+ if segStart >= segEnd:
+ continue
+
+ # Generate points at fixed 100m resolution for performance
+ step = 100
+ xs, ys = [], []
+ x = segStart
+ while x <= segEnd:
+ y = self._calculatePoint(x, miscParams, src, tgt, commonData)
+ xs.append(x)
+ ys.append(y)
+ x += step
+
+ # Always include the segment end point for smooth transitions
+ if xs[-1] < segEnd:
+ y = self._calculatePoint(segEnd, miscParams, src, tgt, commonData)
+ xs.append(segEnd)
+ ys.append(y)
+
+ pyfalog.debug(f"[SEGMENTS] Segment {i} ({ammoName}): {len(xs)} points, y_range=[{min(ys) if ys else 'empty'}, {max(ys) if ys else 'empty'}]")
+
+ segments.append({
+ 'xs': xs,
+ 'ys': ys,
+ 'ammo': ammoName,
+ 'ammoIndex': ammoToIndex[ammoName]
+ })
+
+ pyfalog.debug(f"[SEGMENTS] ========== Returning {len(segments)} segments for {src.item.name} ==========")
+ return segments
+
+
+# =============================================================================
+# Getter Classes
+# =============================================================================
+
+class Distance2OptimalAmmoDpsGetter(XDistanceMixin, YOptimalAmmoDpsMixin):
+ """Distance vs Optimal Ammo DPS graph getter."""
+
+ def getPointExtended(self, x, miscParams, src, tgt):
+ commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt)
+ value, ammo = self._calculatePointExtended(x, miscParams, src, tgt, commonData)
+ return value, {'ammo': ammo}
+
+
+class Distance2OptimalAmmoVolleyGetter(XDistanceMixin, YOptimalAmmoVolleyMixin):
+ """Distance vs Optimal Ammo Volley graph getter."""
+
+ def getPointExtended(self, x, miscParams, src, tgt):
+ commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt)
+ value, ammo = self._calculatePointExtended(x, miscParams, src, tgt, commonData)
+ return value, {'ammo': ammo}
diff --git a/graphs/data/fitDamageProjection/graph.py b/graphs/data/fitDamageProjection/graph.py
new file mode 100644
index 000000000..f4af4a951
--- /dev/null
+++ b/graphs/data/fitDamageProjection/graph.py
@@ -0,0 +1,378 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+from logbook import Logger
+
+from eos.const import FittingHardpoint
+from graphs.data.base import FitGraph, XDef, YDef, Input, VectorDef
+from graphs.data.fitDamageProjection.getter import (
+ Distance2OptimalAmmoDpsGetter,
+ Distance2OptimalAmmoVolleyGetter,
+)
+from graphs.data.fitDamageProjection.calc.turret import getTurretBaseStats
+from graphs.data.fitDamageProjection.calc.charges import getChargeStats
+from graphs.data.fitDamageProjection.calc.valid_charges import getValidChargesForModule
+from graphs.data.fitDamageProjection.calc.launcher import getFlightMultipliers
+from graphs.data.fitDamageStats.cache import ProjectedDataCache
+from service.const import GraphCacheCleanupReason
+from service.settings import GraphSettings
+
+pyfalog = Logger(__name__)
+
+
+class FitDamageProjectionGraph(FitGraph):
+
+ # Graph definition
+ internalName = 'dmgEnvelopeGraph'
+ name = 'Damage Projection'
+ xDefs = [
+ XDef(handle='distance', unit='km', label='Distance', mainInput=('distance', 'km'))]
+ inputs = [
+ Input(handle='distance', unit='km', label='Distance', iconID=None, defaultValue=None, defaultRange=(0, 100), mainTooltip='Distance to target')]
+
+ srcVectorDef = VectorDef(lengthHandle='atkSpeed', lengthUnit='%', angleHandle='atkAngle', angleUnit='degrees', label='Attacker')
+ tgtVectorDef = VectorDef(lengthHandle='tgtSpeed', lengthUnit='%', angleHandle='tgtAngle', angleUnit='degrees', label='Target')
+
+ hasTargets = True
+ srcExtraCols = ('Dps', 'Volley', 'Speed', 'SigRadius', 'Radius')
+
+ @property
+ def tgtExtraCols(self):
+ """Define target extra columns similar to Damage Stats graph"""
+ cols = ['Target Resists', 'Speed', 'SigRadius', 'Radius']
+ return cols
+
+ @property
+ def yDefs(self):
+ ignoreResists = GraphSettings.getInstance().get('ignoreResists')
+ return [
+ YDef(handle='dps', unit=None, label='DPS' if ignoreResists else 'Effective DPS'),
+ YDef(handle='volley', unit=None, label='Volley' if ignoreResists else 'Effective Volley')]
+
+ # Normalizers convert input values to internal units
+ _normalizers = {
+ ('distance', 'km'): lambda v, src, tgt: None if v is None else v * 1000,
+ ('atkSpeed', '%'): lambda v, src, tgt: v / 100 * src.getMaxVelocity(),
+ ('tgtSpeed', '%'): lambda v, src, tgt: v / 100 * tgt.getMaxVelocity()}
+
+ # Denormalizers convert internal units back to display units
+ _denormalizers = {
+ ('distance', 'km'): lambda v, src, tgt: None if v is None else v / 1000,
+ ('tgtSpeed', '%'): lambda v, src, tgt: v * 100 / tgt.getMaxVelocity()}
+
+ # No limiters - allow user to specify any range they want
+ _limiters = {}
+
+ _getters = {
+ ('distance', 'dps'): Distance2OptimalAmmoDpsGetter,
+ ('distance', 'volley'): Distance2OptimalAmmoVolleyGetter}
+
+ def __init__(self):
+ super().__init__()
+ self._projectedCache = ProjectedDataCache()
+ self._rangeCache = {}
+
+ def getDefaultInputRange(self, inputDef, sources):
+ """
+ Calculate dynamic default range based on the turrets/missiles max effective range.
+
+ Returns (min, max) tuple in the input's units (km for distance).
+ For turrets: the longest range ammo's optimal+falloff*2 + 10%, capped at 300km.
+ For missiles: the longest range missile's max range + 10%, capped at 300km.
+ """
+ if inputDef.handle != 'distance' or not sources:
+ return inputDef.defaultRange
+
+ # Build cache key from fit IDs
+ fitIDs = frozenset(src.item.ID for src in sources if src.item is not None)
+ if not fitIDs:
+ return inputDef.defaultRange
+
+ # Check cache
+ if fitIDs in self._rangeCache:
+ return self._rangeCache[fitIDs]
+
+ max_range_m = 0
+
+ for src in sources:
+ fit = src.item
+ if fit is None:
+ continue
+
+ # Check all turrets and missiles
+ for mod in fit.activeModulesIter():
+ if mod.hardpoint == FittingHardpoint.TURRET:
+ if mod.getModifiedItemAttr('miningAmount'):
+ continue
+
+ # Get turret base stats
+ turret_base = getTurretBaseStats(mod)
+
+ # Check all compatible charges for this turret
+ for charge in getValidChargesForModule(mod):
+ charge_stats = getChargeStats(charge)
+
+ # Calculate effective optimal + 2*falloff (where DPS drops to ~6%)
+ effective_optimal = turret_base['optimal'] * charge_stats['rangeMultiplier']
+ effective_falloff = turret_base['falloff'] * charge_stats['falloffMultiplier']
+ effective_max = effective_optimal + effective_falloff * 2.5
+
+ if effective_max > max_range_m:
+ max_range_m = effective_max
+
+ elif mod.hardpoint == FittingHardpoint.MISSILE:
+ # For missiles, check ALL compatible charges to find longest range
+ # We need the max range across all ammo types, not just the loaded one
+
+ valid_charges = list(getValidChargesForModule(mod))
+ if not valid_charges:
+ continue
+
+ # Get flight multipliers from skills/ship (handling empty launcher case)
+ if mod.charge is None:
+ # Temp load first valid charge to extract multipliers
+ temp_charge = valid_charges[0]
+ mod.charge = temp_charge
+ if mod.owner:
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+
+ flight_mults = getFlightMultipliers(mod)
+
+ # Cleanup
+ mod.charge = None
+ if mod.owner:
+ mod.owner.calculated = False
+ mod.owner.calculateModifiedAttributes()
+ else:
+ flight_mults = getFlightMultipliers(mod)
+
+ for charge in valid_charges:
+ base_velocity = charge.getAttribute('maxVelocity') or 0
+ base_explosion_delay = charge.getAttribute('explosionDelay') or 0
+ if base_velocity > 0 and base_explosion_delay > 0:
+ # Apply skill/ship bonuses to flight attributes
+ maxVelocity = base_velocity * flight_mults['maxVelocity']
+ explosionDelay = base_explosion_delay * flight_mults['explosionDelay']
+ # Estimate range: velocity * flight_time
+ flightTime = explosionDelay / 1000
+ estimated_range = maxVelocity * flightTime * 1.1
+ if estimated_range > max_range_m:
+ max_range_m = estimated_range
+
+ if max_range_m <= 0:
+ return inputDef.defaultRange
+
+ # Add 10% buffer and convert to km
+ max_range_km = (max_range_m * 1.1) / 1000
+
+ # Cap at 300km (EVE's max lock range)
+ max_range_km = min(max_range_km, 300)
+
+ # Round to nice number
+ max_range_km = int(max_range_km + 0.5)
+
+ result = (0, max_range_km)
+ self._rangeCache[fitIDs] = result
+ return result
+
+ def _clearInternalCache(self, reason, extraData):
+ pyfalog.debug(f"[CLEAR-CACHE] _clearInternalCache called: reason={reason}, extraData={extraData}")
+
+ if reason in (GraphCacheCleanupReason.fitChanged, GraphCacheCleanupReason.fitRemoved):
+ # extraData is the fit ID (integer), not the fit object
+ fit_id = extraData
+ pyfalog.debug(f"[CLEAR-CACHE] Clearing caches for fit ID {fit_id}")
+
+ # Clear base projected cache for this fit
+ self._projectedCache.clearForFit(fit_id)
+
+ # Clear weapon cache entries for this specific fit only
+ # Cache key format: (fitID, weaponType, qualityTier, tgtResists, applyProjected, tgtSpeed, tgtSigRadius)
+ if hasattr(self, '_ammo_weapon_cache'):
+ keys_to_remove = [k for k in self._ammo_weapon_cache.keys() if k[0] == fit_id]
+ for key in keys_to_remove:
+ del self._ammo_weapon_cache[key]
+ pyfalog.debug(f"[CLEAR-CACHE] Removed {len(keys_to_remove)} weapon cache entries for fit {fit_id}")
+
+ # Clear projected cache entries for this specific fit (all target combinations)
+ # Projected cache key format: (fitID, tgtSpeed, tgtSigRadius)
+ if hasattr(self, '_ammo_projected_cache'):
+ keys_to_remove = [k for k in self._ammo_projected_cache.keys() if k[0] == fit_id]
+ for key in keys_to_remove:
+ del self._ammo_projected_cache[key]
+ pyfalog.debug(f"[CLEAR-CACHE] Removed {len(keys_to_remove)} projected cache entries for fit {fit_id}")
+
+ # Clear range cache entries that include this fit ID
+ if hasattr(self, '_rangeCache'):
+ keys_to_remove = [k for k in self._rangeCache.keys() if fit_id in k]
+ for key in keys_to_remove:
+ del self._rangeCache[key]
+ pyfalog.debug(f"[CLEAR-CACHE] Removed {len(keys_to_remove)} range cache entries for fit {fit_id}")
+
+ # Clear charge cache - when fits change, weapon types might change
+ if hasattr(self, '_ammo_charge_cache'):
+ count = len(self._ammo_charge_cache)
+ self._ammo_charge_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} charge cache entries for fit change")
+
+ elif reason in (GraphCacheCleanupReason.profileChanged, GraphCacheCleanupReason.profileRemoved):
+ profile_id = extraData
+ pyfalog.debug(f"[CLEAR-CACHE] Clearing caches for profile ID {profile_id}")
+
+ if hasattr(self, '_ammo_weapon_cache'):
+ count = len(self._ammo_weapon_cache)
+ self._ammo_weapon_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} weapon cache entries due to profile change")
+
+ if hasattr(self, '_ammo_projected_cache'):
+ count = len(self._ammo_projected_cache)
+ self._ammo_projected_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} projected cache entries due to profile change")
+
+ if hasattr(self, '_rangeCache'):
+ count = len(self._rangeCache)
+ self._rangeCache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} range cache entries due to profile change")
+
+ elif reason == GraphCacheCleanupReason.graphSwitched:
+ self._projectedCache.clearAll()
+ pyfalog.debug(f"[CLEAR-CACHE] Clearing ALL caches for graph switch")
+
+ # Clear all ammo caches globally
+ if hasattr(self, '_ammo_weapon_cache'):
+ count = len(self._ammo_weapon_cache)
+ self._ammo_weapon_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} weapon cache entries")
+
+ if hasattr(self, '_ammo_projected_cache'):
+ count = len(self._ammo_projected_cache)
+ self._ammo_projected_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} projected cache entries")
+
+ if hasattr(self, '_rangeCache'):
+ count = len(self._rangeCache)
+ self._rangeCache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} range cache entries")
+
+ if hasattr(self, '_ammo_charge_cache'):
+ count = len(self._ammo_charge_cache)
+ self._ammo_charge_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} charge cache entries")
+
+ elif reason in (GraphCacheCleanupReason.inputChanged, GraphCacheCleanupReason.optionChanged):
+ pyfalog.debug(f"[CLEAR-CACHE] Clearing ALL caches for {reason.name}")
+
+ if hasattr(self, '_ammo_weapon_cache'):
+ count = len(self._ammo_weapon_cache)
+ self._ammo_weapon_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} weapon cache entries due to {reason.name}")
+
+ if hasattr(self, '_ammo_projected_cache'):
+ count = len(self._ammo_projected_cache)
+ self._ammo_projected_cache = {}
+ pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} projected cache entries due to {reason.name}")
+
+
+ def getPlotSegments(self, mainInput, miscInputs, xSpec, ySpec, src, tgt=None):
+ """
+ Get segmented plot data with ammo information for color coding.
+
+ Returns list of segments, each with xs, ys, ammo name, and ammo index.
+ Returns None if this graph doesn't support segments or getter doesn't have getSegments.
+ """
+ pyfalog.debug(f"[GRAPH] getPlotSegments called for src={src.item.name}, mainInput.value={mainInput.value}")
+ try:
+ getterClass = self._getters[(xSpec.handle, ySpec.handle)]
+ except KeyError:
+ pyfalog.debug(f"[GRAPH] No getter for ({xSpec.handle}, {ySpec.handle})")
+ return None
+
+ # Normalize the input range
+ mainParamRange = self._normalizeMain(mainInput=mainInput, src=src, tgt=tgt)
+ miscParams = self._normalizeMisc(miscInputs=miscInputs, src=src, tgt=tgt)
+ mainParamRange = self._limitMain(mainParamRange=mainParamRange, src=src, tgt=tgt)
+ miscParams = self._limitMisc(miscParams=miscParams, src=src, tgt=tgt)
+ pyfalog.debug(f"[GRAPH] Normalized mainParamRange={mainParamRange}")
+
+ getter = getterClass(graph=self)
+
+ # Check if getter has getSegments method
+ if not hasattr(getter, 'getSegments'):
+ pyfalog.debug(f"[GRAPH] Getter has no getSegments method")
+ return None
+
+ segments = getter.getSegments(
+ xRange=mainParamRange[1],
+ miscParams=miscParams,
+ src=src,
+ tgt=tgt)
+
+ pyfalog.debug(f"[GRAPH] getter.getSegments returned {len(segments) if segments else segments} segments")
+
+ if not segments:
+ pyfalog.debug(f"[GRAPH] No segments, returning None")
+ return None
+
+ # Denormalize the values back to display units
+ for segment in segments:
+ segment['xs'] = self._denormalizeValues(values=segment['xs'], axisSpec=xSpec, src=src, tgt=tgt)
+ segment['ys'] = self._denormalizeValues(values=segment['ys'], axisSpec=ySpec, src=src, tgt=tgt)
+
+ pyfalog.debug(f"[GRAPH] Returning {len(segments)} denormalized segments for {src.item.name}")
+ return segments
+
+ def getPointExtended(self, x, miscInputs, xSpec, ySpec, src, tgt=None):
+ """
+ Get point value with extended info (like ammo name) at x.
+
+ Returns (y_value, extra_info_dict) tuple.
+ extra_info_dict may contain 'ammo' key with the ammo name.
+ """
+ try:
+ getterClass = self._getters[(xSpec.handle, ySpec.handle)]
+ except KeyError:
+ return None, {}
+
+ x = self._normalizeValue(value=x, axisSpec=xSpec, src=src, tgt=tgt)
+ miscParams = self._normalizeMisc(miscInputs=miscInputs, src=src, tgt=tgt)
+ miscParams = self._limitMisc(miscParams=miscParams, src=src, tgt=tgt)
+
+ getter = getterClass(graph=self)
+
+ # Check if getter has getPointExtended method
+ if hasattr(getter, 'getPointExtended'):
+ y, extraInfo = getter.getPointExtended(x=x, miscParams=miscParams, src=src, tgt=tgt)
+ y = self._denormalizeValue(value=y, axisSpec=ySpec, src=src, tgt=tgt)
+ return y, extraInfo
+ else:
+ # Fall back to regular getPoint
+ y = self._getPoint(x=x, miscParams=miscParams, xSpec=xSpec, ySpec=ySpec, src=src, tgt=tgt)
+ y = self._denormalizeValue(value=y, axisSpec=ySpec, src=src, tgt=tgt)
+ return y, {}
+
+ def _updateMiscParams(self, **kwargs):
+ miscParams = super()._updateMiscParams(**kwargs)
+ # Set defaults from target profile
+ miscParams['tgtSigRadius'] = miscParams['tgt'].getSigRadius()
+ miscParams['tgtSpeed'] = miscParams['tgt'].getMaxVelocity()
+ miscParams.setdefault('atkSpeed', 0)
+ miscParams.setdefault('atkAngle', 0)
+ miscParams.setdefault('tgtAngle', 0)
+ return miscParams