From b4262e68252c365e791d0a41ef13ec21e3cd97d4 Mon Sep 17 00:00:00 2001 From: Nicolas Kion Date: Fri, 22 May 2026 19:30:51 +0200 Subject: [PATCH] Reimplement damage projection graph Fixes several issues with the previous implementation. Co-authored-by: Sven --- graphs/data/__init__.py | 2 +- graphs/data/fitDamageEnvelope/getter.py | 317 ----- graphs/data/fitDamageEnvelope/graph.py | 72 -- .../__init__.py | 4 +- .../data/fitDamageProjection/calc/__init__.py | 24 + .../data/fitDamageProjection/calc/charges.py | 241 ++++ .../data/fitDamageProjection/calc/launcher.py | 697 +++++++++++ .../fitDamageProjection/calc/optimize_ammo.py | 216 ++++ .../fitDamageProjection/calc/projected.py | 263 +++++ .../data/fitDamageProjection/calc/turret.py | 187 +++ .../fitDamageProjection/calc/valid_charges.py | 79 ++ graphs/data/fitDamageProjection/getter.py | 1017 +++++++++++++++++ graphs/data/fitDamageProjection/graph.py | 378 ++++++ 13 files changed, 3105 insertions(+), 392 deletions(-) delete mode 100644 graphs/data/fitDamageEnvelope/getter.py delete mode 100644 graphs/data/fitDamageEnvelope/graph.py rename graphs/data/{fitDamageEnvelope => fitDamageProjection}/__init__.py (91%) create mode 100644 graphs/data/fitDamageProjection/calc/__init__.py create mode 100644 graphs/data/fitDamageProjection/calc/charges.py create mode 100644 graphs/data/fitDamageProjection/calc/launcher.py create mode 100644 graphs/data/fitDamageProjection/calc/optimize_ammo.py create mode 100644 graphs/data/fitDamageProjection/calc/projected.py create mode 100644 graphs/data/fitDamageProjection/calc/turret.py create mode 100644 graphs/data/fitDamageProjection/calc/valid_charges.py create mode 100644 graphs/data/fitDamageProjection/getter.py create mode 100644 graphs/data/fitDamageProjection/graph.py 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