Skip to content

Commit 2564cbd

Browse files
committed
CHANGES:
- /sorties displays average flighttime and average survival time now
1 parent 6f04e7d commit 2564cbd

File tree

7 files changed

+130
-41
lines changed

7 files changed

+130
-41
lines changed

core/report/elements.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import inspect
66
import numpy as np
77
import os
8+
import pandas as pd
89
import re
910
import sys
1011
import traceback
@@ -16,6 +17,7 @@
1617
from datetime import timedelta, datetime
1718
from discord import ButtonStyle, Interaction
1819
from io import BytesIO
20+
from matplotlib.axes import Axes
1921
from matplotlib import pyplot as plt
2022
from psycopg.rows import dict_row
2123
from typing import Any, TYPE_CHECKING
@@ -32,6 +34,7 @@
3234
warnings.filterwarnings('ignore', category=UserWarning, module='matplotlib')
3335

3436
__all__ = [
37+
"df_to_table",
3538
"ReportElement",
3639
"EmbedElement",
3740
"Image",
@@ -68,6 +71,36 @@ def get_supported_fonts() -> set[str]:
6871
return _languages
6972

7073

74+
def df_to_table(ax: Axes, df: pd.DataFrame, *, col_labels: list[str] = None, fontsize: int | None = 10) -> Axes:
75+
df = df.copy()
76+
for col in df.select_dtypes(include='timedelta64[ns]').columns:
77+
df[col] = df[col].dt.total_seconds().apply(utils.convert_time)
78+
79+
ax.axis('off')
80+
ax.set_frame_on(False)
81+
table = ax.table(
82+
cellText=df.values,
83+
colLabels=df.columns if col_labels is None else col_labels,
84+
cellLoc='center',
85+
loc='upper left',
86+
)
87+
table.auto_set_font_size(False)
88+
table.set_fontsize(fontsize)
89+
for i in range(len(df.columns)):
90+
table.auto_set_column_width(i)
91+
table.scale(1, 1.5)
92+
for (row, col), cell in table.get_celld().items():
93+
if row == 0: # header row
94+
cell.set_facecolor('#4c9f44') # dark green
95+
cell.set_text_props(weight='bold', color='white')
96+
else:
97+
# alternate row colors for readability
98+
bg = '#e8f5e9' if row % 2 else '#ffffff'
99+
cell.set_facecolor(bg)
100+
cell.set_text_props(color='black')
101+
return ax
102+
103+
71104
class ReportElement(ABC):
72105
def __init__(self, env: ReportEnv):
73106
self.env = env
@@ -232,9 +265,17 @@ def __init__(self, env: ReportEnv, width: int, height: int, cols: int, rows: int
232265

233266
def _plot(self):
234267
plt.subplots_adjust(wspace=self.wspace, hspace=self.hspace)
235-
self.env.filename = f'{uuid.uuid4()}.png'
268+
269+
# ask the renderer for the tight bounding box (in pixels)
270+
renderer = self.env.figure.canvas.get_renderer()
271+
tight_bbox = self.env.figure.get_tightbbox(renderer)
272+
273+
# convert that pixel‑bbox to inches and resize the figure
274+
fig_w, fig_h = tight_bbox.width, tight_bbox.height
275+
self.env.figure.set_size_inches(fig_w, fig_h, forward=True)
236276

237277
# Save with adjusted dimensions while maintaining aspect ratio
278+
self.env.filename = f'{uuid.uuid4()}.png'
238279
self.env.buffer = BytesIO()
239280
self.env.figure.savefig(
240281
self.env.buffer,
@@ -244,10 +285,6 @@ def _plot(self):
244285
)
245286
self.env.buffer.seek(0)
246287

247-
async def _async_plot(self):
248-
async with self.plot_lock:
249-
self._plot()
250-
251288
async def render(self, **kwargs):
252289
plt.style.use('dark_background')
253290
plt.rcParams['axes.facecolor'] = self.facecolor
@@ -296,7 +333,8 @@ async def render(self, **kwargs):
296333

297334
# only render the graph if we don't have a rendered graph already attached as a file (image)
298335
if not self.env.filename:
299-
await self._async_plot()
336+
async with self.plot_lock:
337+
await asyncio.to_thread(self._plot)
300338
self.env.embed.set_image(url='attachment://' + os.path.basename(self.env.filename))
301339
footer = self.env.embed.footer.text or ''
302340
if footer is None:

plugins/competitive/commands.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ async def init_trueskill(self):
9797

9898
async def update_ucid(self, conn: psycopg.AsyncConnection, old_ucid: str, new_ucid: str) -> None:
9999
await conn.execute('UPDATE trueskill SET player_ucid = %s WHERE player_ucid = %s', (new_ucid, old_ucid))
100+
await conn.execute('UPDATE trueskill_hist SET player_ucid = %s WHERE player_ucid = %s', (new_ucid, old_ucid))
100101

101102
async def _trueskill_player(self, interaction: discord.Interaction, user: discord.Member | str) -> None:
102103
if not user:

plugins/mission/listener.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,7 @@ async def linkme(self, server: Server, player: Player, params: list[str]):
10221022
player.verified = True
10231023
async with self.apool.connection() as conn:
10241024
async with conn.transaction():
1025-
# now check, if there was an old validated mapping for this discord_id (meaning the UCID has changed)
1025+
# now check if there was an old validated mapping for this discord_id (meaning the UCID has changed)
10261026
cursor = await conn.execute("SELECT ucid FROM players WHERE discord_id = %s and ucid != %s",
10271027
(discord_id, player.ucid))
10281028
row = await cursor.fetchone()

plugins/missionstats/commands.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,12 @@ async def sorties(self, interaction: discord.Interaction,
121121
await interaction.response.defer(ephemeral=True)
122122
report = Report(self.bot, self.plugin_name, 'sorties.json')
123123
env = await report.render(ucid=ucid, member_name=name, flt=period)
124-
await interaction.followup.send(embed=env.embed, ephemeral=True)
124+
try:
125+
file = discord.File(fp=env.buffer, filename=env.filename)
126+
await interaction.followup.send(embed=env.embed, file=file, ephemeral=True)
127+
finally:
128+
if env.buffer:
129+
env.buffer.close()
125130

126131
@command(description=_('Module statistics'))
127132
@app_commands.guild_only()

plugins/missionstats/reports.py

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import numpy as np
12
import pandas as pd
23

3-
from core import report, ReportEnv, utils, Side, Coalition, get_translation
4+
from core import report, ReportEnv, utils, Side, Coalition, get_translation, df_to_table
45
from dataclasses import dataclass
56
from datetime import datetime
67
from plugins.userstats.filter import StatisticsFilter
@@ -14,29 +15,37 @@ class Flight:
1415
start: datetime = None
1516
end: datetime = None
1617
plane: str = None
18+
death: bool = False
1719

1820

19-
class Sorties(report.EmbedElement):
21+
class Sorties(report.GraphElement):
2022

21-
def __init__(self, env: ReportEnv) -> None:
22-
super().__init__(env)
23-
self.sorties = pd.DataFrame(columns=['plane', 'time'])
23+
def __init__(self, env: ReportEnv, rows: int, cols: int, row: int | None = 0, col: int | None = 0,
24+
colspan: int | None = 1, rowspan: int | None = 1, polar: bool | None = False):
25+
super().__init__(env, rows, cols, row, col, colspan, rowspan, polar)
26+
self.sorties = pd.DataFrame(columns=['plane', 'time', 'death'])
2427

2528
def add_flight(self, flight: Flight) -> Flight:
2629
if flight.start and flight.end and flight.plane:
27-
self.sorties.loc[len(self.sorties.index)] = [flight.plane, flight.end - flight.start]
30+
self.sorties.loc[len(self.sorties.index)] = [flight.plane, flight.end - flight.start, flight.death]
2831
return Flight()
2932

3033
async def render(self, ucid: str, flt: StatisticsFilter) -> None:
31-
sql = """
34+
sql = f"""
3235
SELECT mission_id, init_type, init_cat, event, place, time
3336
FROM missionstats s
34-
WHERE event IN ('S_EVENT_BIRTH', 'S_EVENT_TAKEOFF', 'S_EVENT_LAND', 'S_EVENT_UNIT_LOST',
35-
'S_EVENT_PLAYER_LEAVE_UNIT')
37+
WHERE event IN (
38+
'S_EVENT_BIRTH',
39+
'S_EVENT_TAKEOFF',
40+
'S_EVENT_LAND',
41+
'S_EVENT_UNIT_LOST',
42+
'S_EVENT_PLAYER_LEAVE_UNIT'
43+
)
44+
AND {flt.filter(self.env.bot)}
45+
AND init_id = %s
46+
ORDER BY id
3647
"""
3748
self.env.embed.title = flt.format(self.env.bot) + self.env.embed.title
38-
sql += ' AND ' + flt.filter(self.env.bot)
39-
sql += ' AND init_id = %s ORDER BY 6'
4049

4150
async with self.apool.connection() as conn:
4251
async with conn.cursor(row_factory=dict_row) as cursor:
@@ -66,24 +75,39 @@ async def render(self, ucid: str, flt: StatisticsFilter) -> None:
6675
flight.start = row['time']
6776
elif row['event'] in ['S_EVENT_LAND', 'S_EVENT_UNIT_LOST', 'S_EVENT_PLAYER_LEAVE_UNIT']:
6877
flight.end = row['time']
78+
if row['event'] == 'S_EVENT_UNIT_LOST':
79+
flight.death = True
6980
flight = self.add_flight(flight)
7081
df = self.sorties.groupby('plane').agg(
71-
count=('time', 'size'), total_time=('time', 'sum')).sort_values(by=['total_time'],
72-
ascending=False).reset_index()
73-
planes = sorties = times = ''
74-
for index, row in df.iterrows():
75-
planes += row['plane'] + '\n'
76-
sorties += str(row['count']) + '\n'
77-
times += utils.convert_time(row['total_time'].total_seconds()) + '\n'
78-
if len(planes) == 0:
79-
self.add_field(name=_('No sorties found for this player.'), value='_ _')
80-
else:
81-
self.add_field(name=_('Module'), value=planes)
82-
self.add_field(name=_('Sorties'), value=sorties)
83-
self.add_field(name=_('Total Flighttime'), value=times)
84-
self.embed.set_footer(
85-
text=_('Flighttime is the time you were airborne from takeoff to landing / leave or\n'
86-
'airspawn to landing / leave.'))
82+
count=('time', 'size'), total_time=('time', 'sum'), avg_time=('time', 'mean')
83+
).sort_values(by=['total_time'], ascending=False).reset_index()
84+
if df.empty:
85+
self.axes.axis('off')
86+
self.axes.text(0.5, 0.5, _('No sorties found for this player.'), ha='center', va='center',
87+
rotation=45, size=15, transform=self.axes.transAxes)
88+
return
89+
90+
# Sum the time of those flights per plane
91+
survival_sum = self.sorties.groupby('plane')['time'].sum()
92+
93+
# Count how many deaths exist per plane
94+
death_counts = self.sorties[self.sorties.death == True].groupby('plane').size()
95+
interval_counts = death_counts - 1 # pairs = deaths‑1
96+
interval_counts[interval_counts < 0] = np.nan # protect against 0 deaths
97+
98+
# Average survival time
99+
avg_survival = survival_sum / interval_counts
100+
101+
# Merge into the original dataframe
102+
df = df.merge(avg_survival.rename('avg_survival'), on='plane', how='left')
103+
104+
self.axes = df_to_table(
105+
self.axes, df[['plane', 'count', 'total_time', 'avg_time', 'avg_survival']],
106+
col_labels=['Plane', 'Sorties', 'Total Flighttime', 'Avg. Flighttime', 'Avg. Survivaltime']
107+
)
108+
self.env.embed.set_footer(
109+
text=_('Flighttime is the time you were airborne from takeoff to landing / leave or\n'
110+
'airspawn to landing / leave.'))
87111

88112

89113
class MissionStats(report.EmbedElement):
@@ -214,15 +238,21 @@ async def render(self, ucid: str, module: str, flt: StatisticsFilter) -> None:
214238

215239
class Refuelings(report.EmbedElement):
216240
async def render(self, ucid: str, flt: StatisticsFilter) -> None:
217-
sql = "SELECT init_type, COUNT(*) FROM missionstats WHERE EVENT = 'S_EVENT_REFUELING_STOP'"
241+
sql = f"""
242+
SELECT init_type, COUNT(*)
243+
FROM missionstats
244+
WHERE EVENT = 'S_EVENT_REFUELING_STOP'
245+
AND {flt.filter(self.env.bot)}
246+
AND init_id = %s
247+
GROUP BY 1
248+
ORDER BY 2 DESC
249+
"""
218250
self.env.embed.title = flt.format(self.env.bot) + self.env.embed.title
219-
sql += ' AND ' + flt.filter(self.env.bot)
220-
sql += ' AND init_id = %s GROUP BY 1 ORDER BY 2 DESC'
221251

222252
modules = []
223253
numbers = []
224254
async with self.apool.connection() as conn:
225-
cursor = await conn.execute(sql, (ucid, ))
255+
cursor = await conn.execute(sql, (ucid,))
226256
async for row in cursor:
227257
modules.append(row[0])
228258
numbers.append(str(row[1]))

plugins/missionstats/reports/sorties.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,22 @@
44
"elements":
55
[
66
{
7-
"class": "plugins.missionstats.reports.Sorties"
7+
"type": "Graph",
8+
"params": {
9+
"width": 1,
10+
"height": 1,
11+
"rows": 1,
12+
"cols": 1,
13+
"elements": [
14+
{
15+
"class": "plugins.missionstats.reports.Sorties",
16+
"params": {
17+
"row": 0,
18+
"col": 0
19+
}
20+
}
21+
]
22+
}
823
}
924
]
1025
}

plugins/monitoring/serverstats.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ async def render(self, server_name: str | None, interval: str | None = '1 month'
536536

537537
# Merge data with all possible hours (left join) and fill missing user counts with 0
538538
merged_df = pd.merge(all_hours, df, on='time', how='left')
539-
merged_df['users'] = merged_df['users'].fillna(0)
539+
merged_df['users'] = merged_df['users'].astype(float).fillna(0)
540540

541541
# Step 4: Create the bar plot
542542
barplot = sns.barplot(x='time', y='users', data=merged_df, ax=self.axes, color='dodgerblue')

0 commit comments

Comments
 (0)