amd-debug-tools 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of amd-debug-tools might be problematic. Click here for more details.

@@ -0,0 +1,453 @@
1
+ #!/usr/bin/python3
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import os
5
+ import re
6
+ import math
7
+ from datetime import datetime, timedelta
8
+ from tabulate import tabulate
9
+ from jinja2 import Environment, FileSystemLoader
10
+ import pandas as pd
11
+
12
+ from amd_debug.database import SleepDatabase
13
+ from amd_debug.common import (
14
+ AmdTool,
15
+ Colors,
16
+ version,
17
+ clear_temporary_message,
18
+ get_group_color,
19
+ get_log_priority,
20
+ print_color,
21
+ print_temporary_message,
22
+ )
23
+
24
+ from amd_debug.failures import (
25
+ SpuriousWakeup,
26
+ LowHardwareSleepResidency,
27
+ )
28
+ from amd_debug.wake import WakeIRQ, WakeGPIO
29
+
30
+
31
+ def remove_duplicates(x):
32
+ """Remove duplicates from a string"""
33
+ temp = re.findall(r"\d+", x)
34
+ res = list(map(int, temp))
35
+ return list(set(res))
36
+
37
+
38
+ def format_gpio_as_str(x):
39
+ """Format GPIO as a nicer format"""
40
+ ret = []
41
+ for y in remove_duplicates(x):
42
+ ret.append(str(WakeGPIO(y)))
43
+ return ", ".join(ret)
44
+
45
+
46
+ def format_irq_as_str(x):
47
+ """Format IRQ as a nicer format"""
48
+ ret = []
49
+ for y in remove_duplicates(x):
50
+ ret.append(str(WakeIRQ(y)))
51
+ return ", ".join(ret)
52
+
53
+
54
+ def format_as_human(x):
55
+ """Format as a human readable date"""
56
+ return datetime.strptime(str(x), "%Y%m%d%H%M%S")
57
+
58
+
59
+ def format_as_seconds(x):
60
+ """Format as seconds"""
61
+ return format_as_human(x).timestamp()
62
+
63
+
64
+ def format_watts(val):
65
+ """Format watts as a nicer format"""
66
+ return f"{val:.02f}W"
67
+
68
+
69
+ def format_percent(val):
70
+ """Format percent as a nicer format"""
71
+ return f"{val:.02f}%"
72
+
73
+
74
+ def format_timedelta(val):
75
+ """Format seconds as a nicer format"""
76
+ return str(timedelta(seconds=val))
77
+
78
+
79
+ def parse_hw_sleep(hw):
80
+ """Parse the hardware sleep value, throwing out garbage values"""
81
+ if hw > 1:
82
+ return 0
83
+ return hw * 100
84
+
85
+
86
+ class SleepReport(AmdTool):
87
+ """Sleep report class"""
88
+
89
+ def __init__(self, since, until, fname, fmt, tool_debug, report_debug):
90
+ log_prefix = "s2idle" if tool_debug else None
91
+ super().__init__(log_prefix)
92
+
93
+ self.db = SleepDatabase()
94
+ self.fname = fname
95
+ self.since = since
96
+ self.until = until
97
+ self.debug = report_debug
98
+ self.format = fmt
99
+ self.failures = []
100
+ self.df = self.db.report_summary_dataframe(self.since, self.until)
101
+ self.pre_process_dataframe()
102
+ self.battery_svg = None
103
+ self.hwsleep_svg = None
104
+
105
+ def analyze_duration(self, index, t0, t1, requested, hw):
106
+ """Analyze the duration of the cycle"""
107
+ duration = t1 - t0
108
+ if duration.total_seconds() >= 60 and hw < 90:
109
+ failure = LowHardwareSleepResidency(duration.seconds, hw)
110
+ problem = failure.get_description()
111
+ data = str(failure)
112
+ if self.format == "html":
113
+ self.failures.append(
114
+ {"cycle_num": index, "problem": problem, "data": data}
115
+ )
116
+ else:
117
+ self.failures.append([index, problem, data])
118
+
119
+ if not math.isnan(requested):
120
+ min_suspend_duration = timedelta(seconds=requested * 0.9)
121
+ expected_wake_time = t0 + min_suspend_duration
122
+
123
+ if t1 < expected_wake_time:
124
+ failure = SpuriousWakeup(requested, duration)
125
+ problem = failure.get_description()
126
+ data = str(failure)
127
+ if self.format == "html":
128
+ self.failures.append(
129
+ {"cycle_num": index, "problem": problem, "data": data}
130
+ )
131
+ else:
132
+ self.failures.append([index, problem, data])
133
+
134
+ def pre_process_dataframe(self):
135
+ """Pre-process the pandas dataframe"""
136
+ self.df["Duration"] = self.df["t1"].apply(format_as_seconds) - self.df[
137
+ "t0"
138
+ ].apply(format_as_seconds)
139
+ self.df["Hardware Sleep"] = (self.df["hw"] / self.df["Duration"]).apply(
140
+ parse_hw_sleep
141
+ )
142
+ if not self.df["b0"].isnull().all():
143
+ self.df["Battery Start"] = self.df["b0"] / self.df["full"] * 100
144
+ self.df["Battery Delta"] = (
145
+ (self.df["b1"] - self.df["b0"]) / self.df["full"] * 100
146
+ )
147
+ self.df["Battery Ave Rate"] = (
148
+ (self.df["b1"] - self.df["b0"]) / self.df["Duration"] / 360
149
+ )
150
+
151
+ # Wake sources
152
+ self.df["Wake Pin"] = self.df["gpio"].apply(format_gpio_as_str)
153
+ self.df["Wake Interrupt"] = self.df["wake_irq"].apply(format_irq_as_str)
154
+ del self.df["gpio"]
155
+ del self.df["wake_irq"]
156
+
157
+ # Look for spurious wakeups and low hardware residency
158
+ [
159
+ self.analyze_duration(index, t0, t1, requested, hw)
160
+ for index, t0, t1, requested, hw in zip(
161
+ self.df.index,
162
+ self.df["t0"].apply(format_as_human),
163
+ self.df["t1"].apply(format_as_human),
164
+ self.df["requested"],
165
+ self.df["Hardware Sleep"],
166
+ )
167
+ ]
168
+ del self.df["requested"]
169
+
170
+ # Only keep data needed
171
+ self.df.rename(columns={"t0": "Start Time"}, inplace=True)
172
+ self.df["Start Time"] = self.df["Start Time"].apply(format_as_human)
173
+ del self.df["b1"]
174
+ del self.df["b0"]
175
+ del self.df["full"]
176
+ del self.df["t1"]
177
+ del self.df["hw"]
178
+
179
+ def post_process_dataframe(self):
180
+ """Display pandas dataframe in a more user friendly format"""
181
+ self.df["Duration"] = self.df["Duration"].apply(format_timedelta)
182
+ self.df["Hardware Sleep"] = self.df["Hardware Sleep"].apply(format_percent)
183
+ if "Battery Start" in self.df.columns:
184
+ self.df["Battery Start"] = self.df["Battery Start"].apply(format_percent)
185
+ self.df["Battery Delta"] = self.df["Battery Delta"].apply(format_percent)
186
+ self.df["Battery Ave Rate"] = self.df["Battery Ave Rate"].apply(
187
+ format_watts
188
+ )
189
+
190
+ def convert_gpio_dataframe(self, content):
191
+ header = False
192
+ rows = []
193
+ for line in content.split("\n"):
194
+ # only include header once
195
+ if "int|active" in line:
196
+ if header:
197
+ continue
198
+ header = True
199
+ if "|" in line:
200
+ # first column missing '|'
201
+ rows.append(line.replace("\t", "|"))
202
+ columns = [row.split("|") for row in rows]
203
+ df = pd.DataFrame(columns[1:], columns=columns[0])
204
+ return df.to_html(index=False, table_id="gpio")
205
+
206
+ def get_prereq_data(self):
207
+ """Get the prereq data"""
208
+ prereq = []
209
+ prereq_debug = []
210
+ ts = self.db.get_last_prereq_ts()
211
+ if not ts:
212
+ return [], "", []
213
+ t0 = datetime.strptime(str(ts), "%Y%m%d%H%M%S")
214
+ for row in self.db.report_prereq(t0):
215
+ prereq.append({"symbol": row[3], "text": row[2]})
216
+ if self.debug:
217
+ for row in self.db.report_debug(t0):
218
+ content = row[0]
219
+ if self.format == "html" and "int|active" in content:
220
+ content = self.convert_gpio_dataframe(content)
221
+ prereq_debug.append(
222
+ {"data": "{message}".format(message=content.strip())}
223
+ )
224
+ return prereq, t0, prereq_debug
225
+
226
+ def get_cycle_data(self):
227
+ """Get the cycle data"""
228
+ cycles = []
229
+ debug = []
230
+ num = 0
231
+ for cycle in self.df["Start Time"]:
232
+ if self.format == "html":
233
+ data = ""
234
+ for line in self.db.report_cycle_data(cycle).split("\n"):
235
+ data += "<p>{line}</p>".format(line=line)
236
+ cycles.append({"cycle_num": num, "data": data})
237
+ else:
238
+ cycles.append([num, self.db.report_cycle_data(cycle)])
239
+ if self.debug:
240
+ messages = []
241
+ priorities = []
242
+ for row in self.db.report_debug(cycle):
243
+ messages.append(row[0])
244
+ priorities.append(get_log_priority(row[1]))
245
+ debug.append(
246
+ {"cycle_num": num, "messages": messages, "priorities": priorities}
247
+ )
248
+ num += 1
249
+ return cycles, debug
250
+
251
+ def build_template(self, inc_prereq) -> str:
252
+ """Build the template for the report using jinja2"""
253
+ import amd_debug # pylint: disable=import-outside-toplevel
254
+
255
+ # Load the template
256
+ p = os.path.dirname(amd_debug.__file__)
257
+ environment = Environment(loader=FileSystemLoader(os.path.join(p, "templates")))
258
+ template = environment.get_template(self.format)
259
+
260
+ # Load the prereq data
261
+ prereq = None
262
+ prereq_debug = None
263
+ prereq_date = None
264
+ if inc_prereq:
265
+ prereq, prereq_date, prereq_debug = self.get_prereq_data()
266
+
267
+ # Load the cycle and/or debug data
268
+ cycles, debug = self.get_cycle_data()
269
+
270
+ self.post_process_dataframe()
271
+ failures = None
272
+ if self.format == "md":
273
+ summary = self.df.to_markdown(floatfmt=".02f")
274
+ cycle_data = tabulate(cycles, headers=["Cycle", "data"], tablefmt="pipe")
275
+ if self.failures:
276
+ failures = tabulate(
277
+ self.failures,
278
+ headers=["Cycle", "Problem", "Explanation"],
279
+ tablefmt="pipe",
280
+ )
281
+ elif self.format == "txt":
282
+ summary = tabulate(self.df, headers=self.df.columns, tablefmt="fancy_grid")
283
+ cycle_data = tabulate(
284
+ cycles, headers=["Cycle", "data"], tablefmt="fancy_grid"
285
+ )
286
+ if self.failures:
287
+ failures = tabulate(
288
+ self.failures,
289
+ headers=["Cycle", "Problem", "Explanation"],
290
+ tablefmt="fancy_grid",
291
+ )
292
+ elif self.format == "html":
293
+ summary = ""
294
+ row = 0
295
+ # we will use javascript to highlight the high values
296
+ for line in self.df.to_html(table_id="summary", render_links=True).split(
297
+ "\n"
298
+ ):
299
+ if "<tr>" in line:
300
+ line = line.replace(
301
+ "<tr>",
302
+ '<tr class="row-low" onclick="pick_summary_cycle(%d)">' % row,
303
+ )
304
+ row = row + 1
305
+ summary += line
306
+ cycle_data = cycles
307
+ failures = self.failures
308
+ # only show one cycle in stdout output even if we found more
309
+ else:
310
+ df = self.df.tail(1)
311
+ summary = tabulate(
312
+ df, headers=self.df.columns, tablefmt="fancy_grid", showindex=False
313
+ )
314
+ if cycles[-1][0] == df.index.start:
315
+ cycle_data = cycles[-1][-1]
316
+ else:
317
+ cycle_data = None
318
+ if self.failures and self.failures[-1][0] == df.index.start:
319
+ failures = self.failures[-1][-1]
320
+
321
+ # let it burn
322
+ context = {
323
+ "prereq": prereq,
324
+ "prereq_date": prereq_date,
325
+ "cycle_data": cycle_data,
326
+ "summary": summary,
327
+ "prereq_debug_data": prereq_debug,
328
+ "debug_data": debug,
329
+ "date": datetime.now(),
330
+ "version": version(),
331
+ "battery_svg": self.battery_svg,
332
+ "hwsleep_svg": self.hwsleep_svg,
333
+ "failures": failures,
334
+ }
335
+ if self.fname:
336
+ with open(self.fname, "w") as f:
337
+ f.write(template.render(context))
338
+ if "SUDO_UID" in os.environ:
339
+ os.chown(
340
+ self.fname, int(os.environ["SUDO_UID"]), int(os.environ["SUDO_GID"])
341
+ )
342
+ return "Report written to {f}".format(f=self.fname)
343
+ else:
344
+ return template.render(context)
345
+
346
+ def build_battery_chart(self):
347
+ """Build a battery chart using matplotlib and seaborn"""
348
+ import matplotlib.pyplot as plt
349
+ import seaborn as sns
350
+ import io
351
+
352
+ if "Battery Ave Rate" not in self.df.columns:
353
+ return
354
+
355
+ plt.set_loglevel("warning")
356
+ fig, ax1 = plt.subplots()
357
+ lns3 = ax1.plot(
358
+ self.df["Battery Ave Rate"], color="green", label="Charge/Discharge Rate"
359
+ )
360
+
361
+ ax2 = ax1.twinx()
362
+ lns1 = sns.barplot(
363
+ x=self.df.index,
364
+ y=self.df["Battery Delta"],
365
+ color="grey",
366
+ label="Battery Change",
367
+ alpha=0.3,
368
+ )
369
+ max = int(len(self.df.index) / 10)
370
+ if max:
371
+ ax1.set_xticks(range(0, len(self.df.index), max))
372
+ ax1.set_xlabel("Cycle")
373
+ ax1.set_ylabel("Rate (Watts)")
374
+ ax2.set_ylabel("Battery Change (%)")
375
+
376
+ lines, labels = ax1.get_legend_handles_labels()
377
+ lines2, labels2 = ax2.get_legend_handles_labels()
378
+ ax2.legend(
379
+ lines + lines2, labels + labels2, loc="lower left", bbox_to_anchor=(0, 1)
380
+ )
381
+ battery_svg = io.BytesIO()
382
+ plt.savefig(battery_svg, format="svg")
383
+ battery_svg.seek(0)
384
+ self.battery_svg = battery_svg.read().decode("utf-8")
385
+
386
+ def build_hw_sleep_chart(self):
387
+ """Build the hardware sleep chart using matplotlib and seaborn"""
388
+ import matplotlib.pyplot as plt
389
+ import seaborn as sns
390
+ import io
391
+
392
+ plt.set_loglevel("warning")
393
+ fig, ax1 = plt.subplots()
394
+ lns3 = ax1.plot(
395
+ self.df["Hardware Sleep"],
396
+ color="red",
397
+ label="Hardware Sleep",
398
+ )
399
+
400
+ ax2 = ax1.twinx()
401
+ lns1 = sns.barplot(
402
+ x=self.df.index,
403
+ y=self.df["Duration"] / 60,
404
+ color="grey",
405
+ label="Cycle Duration",
406
+ alpha=0.3,
407
+ )
408
+
409
+ max = int(len(self.df.index) / 10)
410
+ if max:
411
+ ax1.set_xticks(range(0, len(self.df.index), max))
412
+ ax1.set_xlabel("Cycle")
413
+ ax1.set_ylabel("Percent")
414
+ ax2.set_yscale("log")
415
+ ax2.set_ylabel("Duration (minutes)")
416
+
417
+ lines, labels = ax1.get_legend_handles_labels()
418
+ lines2, labels2 = ax2.get_legend_handles_labels()
419
+ ax2.legend(
420
+ lines + lines2, labels + labels2, loc="lower left", bbox_to_anchor=(0, 1)
421
+ )
422
+ hwsleep_svg = io.BytesIO()
423
+ plt.savefig(hwsleep_svg, format="svg")
424
+ hwsleep_svg.seek(0)
425
+ self.hwsleep_svg = hwsleep_svg.read().decode("utf-8")
426
+
427
+ def run(self, inc_prereq=True):
428
+ """Run the report"""
429
+
430
+ if self.df.empty:
431
+ raise ValueError(f"No data found between {self.since} and {self.until}")
432
+
433
+ characters = print_temporary_message("Building report, please wait...")
434
+
435
+ # Build charts in the page for html and markdown formats
436
+ if len(self.df.index) > 1 and (self.format == "html" or self.format == "md"):
437
+ self.build_battery_chart()
438
+ self.build_hw_sleep_chart()
439
+
440
+ # Render the template using jinja
441
+ msg = self.build_template(inc_prereq)
442
+ clear_temporary_message(characters)
443
+ for line in msg.split("\n"):
444
+ color = Colors.OK
445
+ text = line.strip()
446
+ if not text:
447
+ continue
448
+ for group in ["🗣️", "❌", "🚦", "🦟", "💯", "○"]:
449
+ if line.startswith(group):
450
+ text = line.split(group)[-1]
451
+ color = get_group_color(group)
452
+ break
453
+ print_color(text, color)