Analysis Helpers
Post-parse utilities in gem.analysis that transform raw ParsedMatch / ParsedPlayer data into higher-level structures for agentic and analytical use.
Note:
estimate_visionandmatch.vision_modifiersare experimental. Vision calculations use straight-line geometry only — high-ground penalties, terrain line-of-sight (trees/cliffs), and per-hero vision range modifiers are not modelled. Treat results as approximations.For the full derivation, data flow, and limitations, see Experimental Features → Estimate Vision.
Canonical implementation modules are split by responsibility:
gem.analysis.spatial— position, nearby-hero, and net-worth lookup helpersgem.analysis.combat— ability-hit grouping and teamfight helpersgem.analysis.abilities— ability-level lookup helpersgem.analysis.vision— geometry-based vision approximation helpersgem.analysis.map_context— objective-aware farming context helpersgem.analysis.roshan— Roshan conversion summaries
gem.analysis re-exports the public helpers below. Use gem.analysis.map_context and gem.analysis.roshan for module-level imports.
All functions are exported directly from gem.*:
import gem
pos = gem.position_at_tick(player, tick)
casts = gem.group_ability_hits(match.combat_log)
fight = gem.teamfight_at_tick(match, tick)
near = gem.heroes_near(match, tick, x, y, radius=2000)
lvl = gem.ability_level_at_tick(player, "axe_berserkers_call", tick)
sources = gem.estimate_vision(match, team=2, tick=tick, x=x, y=y)position_at_tick
gem.position_at_tick(player: ParsedPlayer, tick: int) -> tuple[float, float] | NoneReturn the closest recorded (x, y) position for a player at a given tick.
Searches player.position_log (sampled at ~1-second intervals) for the entry with the smallest tick distance to the requested tick. Ties go to the earlier sample.
Returns None if position_log is empty.
Example:
pos = gem.position_at_tick(axe_player, fight.start_tick)
if pos:
print(f"Axe was at ({pos[0]:.0f}, {pos[1]:.0f}) when the fight started")group_ability_hits
gem.group_ability_hits(
combat_log: list[CombatLogEntry],
window_ticks: int = 5,
) -> list[AbilityCast]Group DAMAGE combat log entries into per-cast AbilityCast records.
Many abilities hit multiple targets simultaneously (Ravage, Black Hole, RP). The combat log emits one DAMAGE entry per target. This function collapses those into a single AbilityCast record with a targets list and total_damage sum.
Only entries with a non-empty inflictor_name are considered (auto-attacks are excluded). Entries from the same (caster, ability) pair within window_ticks are merged.
AbilityCast fields:
cast.tick # int: tick of the first hit
cast.caster # str: NPC name of the casting unit
cast.ability # str: ability/item inflictor name
cast.targets # list[str]: NPC names of all units hit
cast.total_damage # int: sum of all damage values
cast.damage_type # str: damage type of the first hit
cast.stun_duration # float: stun seconds (first hit with a stun)
cast.entries # list[CombatLogEntry]: raw entries that compose this castExample:
casts = gem.group_ability_hits(match.combat_log)
big_hits = [c for c in casts if len(c.targets) >= 3]
for cast in big_hits:
print(f"{cast.caster} hit {len(cast.targets)} heroes with {cast.ability} "
f"for {cast.total_damage:,} total damage")teamfight_at_tick
gem.teamfight_at_tick(match: ParsedMatch, tick: int) -> Teamfight | NoneReturn the Teamfight whose [start_tick, end_tick] window contains tick, or None.
Uses binary search — O(log N). Fights are assumed non-overlapping and sorted by start_tick (as produced by detect_teamfights).
Example:
for entry in match.combat_log:
fight = gem.teamfight_at_tick(match, entry.tick)
if fight:
print(f"Event at tick {entry.tick} during fight won by {fight.winner}")heroes_near
gem.heroes_near(
match: ParsedMatch,
tick: int,
x: float,
y: float,
radius: float,
) -> list[ParsedPlayer]Return all heroes within radius world units of (x, y) at tick, sorted by ascending distance.
Uses position_at_tick internally. Heroes with no position data are excluded.
Example:
# Who was close enough to join the fight at initiation?
if fight.centroid_x is not None:
nearby = gem.heroes_near(match, fight.start_tick,
fight.centroid_x, fight.centroid_y, radius=2000)
for player in nearby:
pos = gem.position_at_tick(player, fight.start_tick)
print(f"{player.hero_name} at {pos}")ability_level_at_tick
gem.ability_level_at_tick(
player: ParsedPlayer,
ability: str,
tick: int,
) -> intReturn the level (1–4) of an ability for a player at a given tick.
Uses per-minute ability level snapshots stored in player._ability_snapshots (populated automatically by gem.parse()). Returns the last known level at or before tick. Returns 0 if the ability was not yet learned.
Ability names match the inflictor_name field in the combat log (e.g. "axe_berserkers_call").
Example:
casts = gem.group_ability_hits(match.combat_log)
for cast in casts:
player = gem.find_player(match, cast.caster)
if player:
lvl = gem.ability_level_at_tick(player, cast.ability, cast.tick)
print(f"{cast.ability} cast at level {lvl}")estimate_vision (experimental)
Detailed explanation:
gem.estimate_vision(
match: ParsedMatch,
team: int,
tick: int,
x: float,
y: float,
) -> list[VisionSource]Estimate which allied units were providing vision of (x, y) at tick for the given team (2=Radiant, 3=Dire).
Returns a list of VisionSource objects sorted by ascending distance. An empty list means the point was in fog for that team.
Checks three sources:
| Source | Radius | Night penalty |
|---|---|---|
| Allied hero | 1800 (day) / 800 (night) | Yes |
| Observer ward | 1600 | No |
| Vision modifier (Slardar, BH Track, Dust, Gem) | N/A — direct reveal | No |
Vision modifiers are tracked from combat log MODIFIER_ADD/MODIFIER_REMOVE events during parse and stored in match.vision_modifiers.
VisionSource fields:
source.kind # "hero", "ward", or "modifier"
source.name # hero NPC name, "observer_ward", or modifier internal name
source.distance # float: world-unit distance from source to query point
source.vision_radius # int: radius used (0 for modifier reveals)Limitations — geometry approximation only:
- No high-ground vision penalties
- No summon/creep vision (only heroes and observer wards)
- No sentry ward true-sight (sentries do not grant standard vision)
- Dust/Gem aura radii are not modelled — modifier reveals are unconditional
Example:
sources = gem.estimate_vision(match, team=3, tick=fight.start_tick,
x=target_x, y=target_y)
if sources:
s = sources[0]
if s.kind == "hero":
print(f"Dire had vision via {s.name} ({s.vision_radius} units away)")
elif s.kind == "modifier":
print(f"Dire had reveal via {s.name} on the target hero")
else:
print("Blind initiation — target was in fog")Vision modifiers (match.vision_modifiers) (experimental)
Detailed explanation:
ParsedMatch.vision_modifiers is a list[VisionModifierEvent] populated by gem.parse(). It tracks every application of a vision-granting ability or item (Slardar Corrosive Haze, Bounty Hunter Track, Dust of Appearance, Gem of True Sight).
VisionModifierEvent fields:
ev.tick # int: tick when modifier was applied
ev.end_tick # int | None: tick when removed, or None if still active at game end
ev.modifier_name # str: e.g. "modifier_slardar_amplify_damage"
ev.target_name # str: NPC name of the revealed hero
ev.caster_name # str: NPC name of the caster
ev.caster_team # int: team of the caster (2=Radiant, 3=Dire)Tracked modifier names:
| Modifier | Source |
|---|---|
modifier_slardar_amplify_damage | Slardar — Corrosive Haze (ultimate) |
modifier_bounty_hunter_track | Bounty Hunter — Track |
modifier_item_dustofappearance | Dust of Appearance (item) |
modifier_item_gem_of_true_sight | Gem of True Sight (item, on target) |
modifier_gem_active_truesight | Gem of True Sight (thinker aura) |
Example — how long was each hero tracked by BH?
for ev in match.vision_modifiers:
if ev.modifier_name == "modifier_bounty_hunter_track":
duration = ((ev.end_tick or match.game_end_tick) - ev.tick) / 30
print(f"{ev.target_name} tracked for {duration:.1f}s")net_worth_at
gem.net_worth_at(player: ParsedPlayer, tick: int) -> intReturn the closest sampled net worth for a player at a given tick.
Uses a linear scan over player.times / player.net_worth_t to find the sample with the smallest tick distance. Returns 0 if no data is available.
Example:
for fight in match.teamfights:
for p in fight.players:
player = match.players[p.player_id]
nw = gem.net_worth_at(player, fight.start_tick)
print(f"{player.hero_name}: {nw:,} NW at fight start")ward_vision_impact
gem.ward_vision_impact(ward, match: ParsedMatch) -> intCount distinct enemy heroes spotted by an observer ward during its lifetime.
Checks whether any hero position sample falls within the 1600-unit observer ward vision radius while the ward was alive. Only the first sighting per hero is counted. Sentry wards return 0.
Approximation
This is a heuristic — terrain, cliffs, trees, and night vision are not modelled. Position samples are taken every ~5 seconds, so fast-moving heroes may be missed.
Example:
for ward in match.wards:
if ward.ward_type == "observer":
impact = gem.ward_vision_impact(ward, match)
print(f"Ward at ({ward.x:.0f}, {ward.y:.0f}) spotted {impact} enemy heroes")is_active_teamfight_participant
gem.is_active_teamfight_participant(player_stats) -> boolReturn True if a player was an active participant in a teamfight — i.e. they had direct hero-vs-hero combat: a death, damage dealt, damage taken, or healing.
Passive presence (farming nearby, casting only on creeps) does not count.
Example:
fight = match.teamfights[0]
active = [p for p in fight.players if gem.is_active_teamfight_participant(p)]
print(f"{len(active)} active participants in fight")format_npc_name
gem.format_npc_name(name: str) -> strConvert an NPC name to a human-readable label by stripping Dota 2 prefixes (npc_dota_, goodguys_, badguys_) and replacing underscores with spaces. For heroes, prefer gem.constants.hero_display() which returns the official display name.
Example:
gem.format_npc_name("npc_dota_goodguys_tower_top_1")
# → "tower top 1"resolve_pick_team
gem.resolve_pick_team(event: DraftEvent, players: list[ParsedPlayer]) -> intResolve the correct team (2=Radiant, 3=Dire) for a draft event. DraftEvent.team comes from m_pGameRules.m_iActiveTeam, which is reliable for bans but can be wrong for picks in HLTV replays and coach-slot edge cases.
For picks, cross-references the hero name against the post-game player roster. For bans, falls back to event.team.
Example:
for event in match.draft:
team = gem.resolve_pick_team(event, match.players)
side = "Radiant" if team == 2 else "Dire"
action = "picks" if event.is_pick else "bans"
print(f"{side} {action} {event.hero_name}")Replay download helpers
fetch_replay
gem.fetch_replay(match_id: int, out_dir: str | Path = ".") -> PathDownload and decompress a replay from OpenDota in one call. Returns the path to the decompressed .dem file.
dem_path = gem.fetch_replay(8734577999, out_dir="replays/")
match = gem.parse(str(dem_path))fetch_replay_url
gem.fetch_replay_url(match_id: int) -> strFetch just the replay download URL from the OpenDota API. Raises ValueError if no replay URL is available.
download_and_decompress
gem.download_and_decompress(match_id: int, replay_url: str, out_dir: str | Path = ".") -> PathLower-level helper: download from a known URL and decompress .bz2 → .dem.
Generated API
Module gem.analysis.spatial
Spatial and time-series helpers for parsed match analysis.
Source: src/gem/analysis/spatial.py
Top-level functions
position_at_tick
def position_at_tick(player: ParsedPlayer, tick: int) -> tuple[float, float] | NoneReturn the closest recorded (x, y) position for a player at a given tick.
Source: src/gem/analysis/spatial.py:13
heroes_near
def heroes_near(match: ParsedMatch, tick: int, x: float, y: float, radius: float) -> list[ParsedPlayer]Return all heroes within radius world units of (x, y) at tick.
Source: src/gem/analysis/spatial.py:56
net_worth_at
def net_worth_at(player: ParsedPlayer, tick: int) -> intReturn the closest sampled net worth for a player at the given tick.
Source: src/gem/analysis/spatial.py:97
Module gem.analysis.combat
Combat-log and teamfight analysis helpers.
Source: src/gem/analysis/combat.py
Top-level functions
group_ability_hits
def group_ability_hits(combat_log: list[CombatLogEntry], window_ticks: int = 5) -> list[AbilityCast]Group DAMAGE combat log entries into per-cast AbilityCast records.
Source: src/gem/analysis/combat.py:41
teamfight_at_tick
def teamfight_at_tick(match: ParsedMatch, tick: int) -> Teamfight | NoneReturn the teamfight window that contains the given tick, or None.
Source: src/gem/analysis/combat.py:115
is_active_teamfight_participant
def is_active_teamfight_participant(player_stats: object) -> boolReturn True if a player was an active participant in a teamfight.
Source: src/gem/analysis/combat.py:148
Top-level classes
AbilityCast
class AbilityCastA single ability (or item) cast with all targets it hit.
Source: src/gem/analysis/combat.py:16
Dataclass fields
| Name | Type | Default |
|---|---|---|
tick | int | - |
caster | str | - |
ability | str | - |
targets | list[str] | field(...) |
total_damage | int | 0 |
damage_type | str | '' |
stun_duration | float | 0.0 |
entries | list[CombatLogEntry] | field(...) |
Module gem.analysis.abilities
Ability-level lookup helpers.
Source: src/gem/analysis/abilities.py
Top-level functions
ability_level_at_tick
def ability_level_at_tick(player: ParsedPlayer, ability: str, tick: int) -> intReturn the level of an ability for a player at a given tick.
Source: src/gem/analysis/abilities.py:12
Module gem.analysis.vision
Vision approximation helpers for parsed matches.
Source: src/gem/analysis/vision.py
Top-level functions
is_daytime
def is_daytime(game_start_tick: int | None, tick: int) -> boolReturn True if it is daytime at the given absolute tick.
Source: src/gem/analysis/vision.py:56
estimate_vision
def estimate_vision(match: ParsedMatch, team: int, tick: int, x: float, y: float) -> list[VisionSource]Estimate which allied units were providing vision of (x, y) at tick.
Source: src/gem/analysis/vision.py:83
ward_vision_impact
def ward_vision_impact(ward: object, match: ParsedMatch) -> intCount distinct enemy heroes spotted by an observer ward during its lifetime.
Source: src/gem/analysis/vision.py:229
Top-level classes
VisionSource
class VisionSourceOne unit that was providing vision of a map point at a given tick.
Source: src/gem/analysis/vision.py:31
Dataclass fields
| Name | Type | Default |
|---|---|---|
kind | Literal['hero', 'ward', 'modifier'] | - |
name | str | - |
distance | float | - |
vision_radius | int | - |
Module gem.analysis.formatting
Formatting helpers for parsed Dota names.
Source: src/gem/analysis/formatting.py
Top-level functions
format_npc_name
def format_npc_name(name: str) -> strConvert an NPC name to a human-readable label.
Source: src/gem/analysis/formatting.py:6
Module gem.analysis.map_context
Objective-aware map-context helpers for farming-pattern analysis.
Source: src/gem/analysis/map_context.py
Top-level functions
build_map_context_timeline
def build_map_context_timeline(match: ParsedMatch, team: int, bucket_ticks: int = _DEFAULT_BUCKET_TICKS, presence_window_ticks: int = _DEFAULT_PRESENCE_WINDOW_TICKS) -> list[MapContextBucket]Build objective-aware context buckets for one team's perspective.
Source: src/gem/analysis/map_context.py:141
score_camp_visit_context
def score_camp_visit_context(*, team: int, camp_id: int, camp_type: str, neutral_kills: int, neutral_damage: int, xp_gain: int, bucket: MapContextBucket) -> CampVisitContextScore one camp visit against a context bucket.
Source: src/gem/analysis/map_context.py:279
world_in_bounds
def world_in_bounds(x: float, y: float) -> boolReturn True when world coordinates are within calibrated map bounds.
Source: src/gem/analysis/map_context.py:433
Top-level classes
MapContextBucket
class MapContextBucketObjective- and vision-aware map-state summary for one time bucket.
Source: src/gem/analysis/map_context.py:40
Dataclass fields
| Name | Type | Default |
|---|---|---|
start_tick | int | - |
end_tick | int | - |
tower_alive_radiant | int | - |
tower_alive_dire | int | - |
t1_mid_alive_radiant | bool | - |
t1_mid_alive_dire | bool | - |
roshan_last_kill_tick | int | None | - |
aegis_holder_team | int | None | - |
aegis_active | bool | - |
tormentor_last_kill_tick | int | None | - |
ward_count_radiant | int | - |
ward_count_dire | int | - |
net_worth_advantage | int | - |
xp_advantage | int | - |
enemy_presence_by_region | dict[str, float] | field(...) |
CampVisitContext
class CampVisitContextContext scores and explainability labels for one camp visit.
Source: src/gem/analysis/map_context.py:61
Dataclass fields
| Name | Type | Default |
|---|---|---|
farm_safety_score | float | - |
pressure_score | float | - |
expected_value_score | float | - |
context_label | Literal['safe_home_farm', 'pressured_home_farm', 'defensive_home_farm', 'safe_invade', 'pressure_invade', 'high_risk_invade'] | - |
context_drivers | list[str] | field(...) |
Module gem.analysis.roshan
Post-parse Roshan conversion analysis.
Source: src/gem/analysis/roshan.py
Top-level functions
build_rosh_conversions
def build_rosh_conversions(match: ParsedMatch) -> list[RoshConversion]Summarise how well each Roshan was converted into advantage.
Source: src/gem/analysis/roshan.py:471
Top-level classes
RoshTimelineEvent
class RoshTimelineEventOne notable event inside a Roshan conversion sequence.
Source: src/gem/analysis/roshan.py:63
Dataclass fields
| Name | Type | Default |
|---|---|---|
tick | int | - |
kind | Literal['roshan', 'aegis_pickup', 'aegis_denied', 'fight_win', 'fight_loss', 'fight_draw', 'tower', 'barracks', 'buyback', 'aegis_end', 'game_end'] | - |
label | str | - |
RoshConversion
class RoshConversionDerived summary for one Roshan kill and the advantage window that followed.
Source: src/gem/analysis/roshan.py:84
Dataclass fields
| Name | Type | Default |
|---|---|---|
rosh_number | int | - |
rosh_tick | int | - |
killer_name | str | - |
holder_team | int | None | - |
holder_player_id | int | None | - |
holder_name | str | - |
aegis_pickup_tick | int | None | - |
immediate_end_tick | int | - |
aegis_end_tick | int | - |
aegis_eval_end_tick | int | - |
extended_end_tick | int | - |
aegis_fate | Literal['consumed', 'expired', 'denied', 'game_end', 'unknown'] | - |
first_fight_tick | int | None | - |
first_objective_tick | int | None | - |
fight_count | int | - |
fights_won | int | - |
fights_lost | int | - |
fights_drawn | int | - |
towers_taken | int | - |
barracks_taken | int | - |
enemy_buybacks_forced | int | - |
enemy_half_observer_delta | int | - |
enemy_half_farm_share_before | float | - |
enemy_half_farm_share_during | float | - |
enemy_half_farm_share_delta | float | - |
conversion_score | int | - |
conversion_label | Literal['low_conversion', 'fight_conversion', 'objective_conversion', 'map_squeeze', 'game_closing_rosh'] | - |
aegis_outcome | Literal['consumed_in_fight', 'expired_after_use', 'expired_unused', 'denied', 'window_lost', 'game_ended', 'unknown'] | - |
drivers | list[str] | field(...) |
timeline_events | list[RoshTimelineEvent] | field(...) |
drops | list[str] | field(...) |
had_high_value_drop | bool | False |
banner_planted | bool | False |
banner_rax_conversion | bool | False |
banner_rax_lane | str | None | None |