tan-cli 0.7.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.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,227 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure serialisation + text rendering for `tan model check` (tan-cli#782).
3
+
4
+ Mirrors `tan.core.model_doctor`'s split: the engine (`tan.model.check`,
5
+ `tan.model.analyze`) returns `BackendReport`s; this module turns THOSE into
6
+ the wire dict `--format json` emits (camelCase, the house convention every
7
+ other command's `data` payload already follows -- `schemaVersion`,
8
+ `boardYaml`, ...) and the lines `--format text` prints. No IO, no vela/dxcom
9
+ probing, nothing that was not already computed by the caller.
10
+
11
+ `model_cmd.py`'s `finish()` renders `check`'s text mode from `data` -- the
12
+ SAME serialised dict `--format json` emits, not a second dataclass-shaped
13
+ code path -- exactly the pattern `doctor`'s own text branch already uses
14
+ (`data["backends"]`): one source of truth for the values, two renderers for
15
+ the format.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from tan.model.analyze import COVERAGE_WITHHELD, BackendReport, OpVerdict
20
+
21
+ #: Cosmetic backend -> text-mode label. `ethos_u` is special-cased in
22
+ #: `backend_label` below to fold in the resolved variant ("Ethos-U55") --
23
+ #: the other two backends carry no per-instance variant to show.
24
+ _BACKEND_LABELS = {"ethos_u": "Ethos-U", "drpai": "DRP-AI", "deepx_dxm1": "DEEPX DX-M1"}
25
+
26
+
27
+ def backend_label(backend: str, variant: str | None) -> str:
28
+ if backend == "ethos_u" and variant and variant.startswith("u"):
29
+ return f"Ethos-U{variant[1:]}"
30
+ return _BACKEND_LABELS.get(backend, backend)
31
+
32
+
33
+ def op_verdict_as_dict(v: OpVerdict) -> dict:
34
+ return {"op": v.op, "status": v.status, "reason": v.reason, "macs": v.macs}
35
+
36
+
37
+ def backend_report_as_dict(report: BackendReport) -> dict:
38
+ """Every `BackendReport` field, camelCase -- including `basis`,
39
+ `confidence` and `uncostedCpuOpCount` (a structured caveat on
40
+ `computeOnNpuPctMax`, not just prose -- see `analyze.BackendReport`'s own
41
+ comment), so a consumer never needs the text rendering to recover a
42
+ number this dict already carries. `computeOnNpuPctMax` and
43
+ `npuPlacementPctReal` are DIFFERENT units answering different questions
44
+ (MAC-weighted upper bound vs. real op-count placement) and are never
45
+ both non-`None` on the same report -- see `analyze.BackendReport`'s
46
+ field comments.
47
+
48
+ The six measured fields (`arenaBytes` ... `perfRef`) are `null` at
49
+ `basis: "static-screen"` and stay structured rather than prose-only, so a
50
+ consumer sizing a module reads a NUMBER instead of parsing tan's own
51
+ sentence: `null` there means "not measured", never zero."""
52
+ return {
53
+ "backend": report.backend,
54
+ "variant": report.variant,
55
+ "table": report.table,
56
+ "npuCoverage": report.npu_coverage,
57
+ "computeOnNpuPctMax": report.compute_on_npu_pct_max,
58
+ "npuPlacementPctReal": report.npu_placement_pct_real,
59
+ "uncostedCpuOpCount": report.uncosted_cpu_op_count,
60
+ "basis": report.basis,
61
+ "confidence": report.confidence,
62
+ "arenaBytes": report.arena_bytes,
63
+ "reqSramKib": report.req_sram_kib,
64
+ "latencyMsMean": report.latency_ms_mean,
65
+ "latencyMsP95": report.latency_ms_p95,
66
+ "latencyRuns": report.latency_runs,
67
+ "perfRef": report.perf_ref,
68
+ "notes": list(report.notes),
69
+ "ops": [op_verdict_as_dict(o) for o in report.ops],
70
+ # tan-cli#1288: `Sram_Only` arena + SRAM0-residency fit. `null` at
71
+ # `basis: "static-screen"` always (no compiled blob to measure a fit
72
+ # against), and at `basis: "bench"` unless the rebase corroborates an
73
+ # already-`"compiled"` report (`analyze.BackendReport.sram_fit`'s own
74
+ # field comment) -- never a guess.
75
+ "sramFit": None if report.sram_fit is None else report.sram_fit.as_dict(),
76
+ }
77
+
78
+
79
+ def _coverage_line(report: dict) -> str | None:
80
+ """`None` on BOTH of `npu_coverage`'s "we don't actually know" spellings
81
+ -- `"undetermined"` itself, and the more subtle case where every `op`
82
+ entry carries `status: "unknown"` (`format-not-accepted`/`no-table-for-
83
+ backend`: `analyze.py` still emits one placeholder verdict per input op
84
+ so a consumer can see WHICH ops were skipped, but none of them was
85
+ actually screened). Counting `status == "unknown"` entries against
86
+ `len(ops)` reads as "0/N ops are NPU-eligible" -- the exact `cpu-only`
87
+ misreading non-negotiables 1 and 2 forbid, reintroduced by the renderer
88
+ (MAJOR 3 review) even though the JSON underneath was already correct.
89
+ Only ever counts verdicts a real screen actually determined
90
+ (`npu-eligible`/`cpu-certain`). Also `None` for a real (`basis:
91
+ "compiled"`) report: `tan.model.check._report_from_vela_compile` keeps
92
+ the STATIC per-op verdicts alongside a real vela placement result
93
+ (`ops=report.ops`, so the caller can still see which ops the table
94
+ thought were eligible), and those can legitimately disagree with what
95
+ vela actually placed (table membership doesn't check dtype/shape) --
96
+ recomputing an "E/T ops are NPU-eligible" figure from them here would
97
+ read alongside the REAL placement percentage as self-contradictory. The
98
+ real split is already `notes`' whole point for that report
99
+ (`"N/Total operators placed on the NPU (P%)"`)."""
100
+ if report["npuCoverage"] == COVERAGE_WITHHELD or report["basis"] != "static-screen":
101
+ return None
102
+ determined = [o for o in report["ops"] if o["status"] in ("npu-eligible", "cpu-certain")]
103
+ if not determined:
104
+ return None
105
+ eligible = sum(1 for o in determined if o["status"] == "npu-eligible")
106
+ total = len(determined)
107
+ pct = report["computeOnNpuPctMax"]
108
+ if pct is not None:
109
+ return (f" {pct:.0f}% of compute ({eligible}/{total} ops) is NPU-eligible"
110
+ f" [upper bound, static screen]")
111
+ return f" {eligible}/{total} ops are NPU-eligible by name (no MAC-weighted figure)"
112
+
113
+
114
+ def _cpu_fallback_line(report: dict) -> str | None:
115
+ """`None` for a real (`basis: "compiled"`) report -- the SAME gate
116
+ `_coverage_line` carries, for the same reason (MAJOR 3 review: this line
117
+ did NOT get that gate in the commit that added it to `_coverage_line`,
118
+ so a compiled report could still print an op-level "N ops are certain
119
+ CPU fallback" claim built from the kept STATIC verdicts
120
+ (`tan.model.check._report_from_vela_compile`'s `ops=report.ops`) --
121
+ self-contradicting the REAL placement `_coverage_line`/`notes` reports
122
+ alongside it: measured a static screen asserting "2 ops are certain CPU
123
+ fallback" next to a real compile's "1/3 operators placed on the NPU
124
+ (67%)" in the same rendered block, from the same STATIC vs COMPILED
125
+ disagreement `_coverage_line`'s own gate exists to prevent -- table
126
+ membership doesn't check dtype/shape, so the static and compiled
127
+ verdicts can legitimately differ)."""
128
+ if report["basis"] != "static-screen":
129
+ return None
130
+ names = [o["op"] for o in report["ops"] if o["status"] == "cpu-certain"]
131
+ if not names:
132
+ return None
133
+ shown = ", ".join(names[:8])
134
+ if len(names) > 8:
135
+ shown += f", +{len(names) - 8} more"
136
+ noun = "op is" if len(names) == 1 else "ops are"
137
+ return f" {len(names)} {noun} certain CPU fallback: {shown}"
138
+
139
+
140
+ def _exact_hint_line(report: dict) -> str | None:
141
+ """The upgrade-path advertisement -- ethos_u only (the sole backend with
142
+ a free, un-gated compiler), and only while the report is STILL a static
143
+ screen. Suppressed when a note already explains an `--exact` attempt
144
+ (`tan.model.check._exact_ethos_u`'s own notes all open with
145
+ "--exact ..."); printing both would repeat the same install command
146
+ twice in one report. Deliberately NOT a substring check on "vela" --
147
+ every ethos_u table is itself named `<variant>@vela-<ver>.json`
148
+ (`tan.model.analyze._resolve_table`), so the static-screen note ALWAYS
149
+ mentions "vela" via the table filename it cites, which would suppress
150
+ this hint on every ordinary run rather than only after a real attempt."""
151
+ if report["backend"] != "ethos_u" or report["basis"] != "static-screen":
152
+ return None
153
+ if any(n.startswith("--exact") for n in report["notes"]):
154
+ return None
155
+ return " Exact: pip install tan-cli[model-compile] && tan model check --exact"
156
+
157
+
158
+ #: tan-cli#1288 -- text-mode label per `sramFit` axis, matching the JSON
159
+ #: key order (`arena` then `sram0`).
160
+ _SRAM_FIT_AXIS_LABELS = (("arena", "arena"), ("sram0", "SRAM0"))
161
+
162
+
163
+ def _sram_fit_lines(report: dict) -> list[str]:
164
+ """One line per `sramFit` axis, or `[]` when the report carries none (a
165
+ static screen, or a `compiled` report `check.py` never reached --
166
+ `basis` gates it, `model_check.backend_report_as_dict` already made it
167
+ `null` there). Never renders a bare "fits" -- only `fits-unverified` /
168
+ `no-fit` / `skipped`, the same three words the JSON carries.
169
+
170
+ tan-cli#1288 review round 2, finding 4 checked THIS renderer too for the
171
+ same "borrows the wrong axis's `limitKib`" defect `build.py`'s own
172
+ `_sram_no_fit_message` had -- it does not have it: each axis line reads
173
+ ONLY its own `axis[...]` dict below, never the sibling axis's, so there
174
+ is no `arena`/`sram0` figure to cross up."""
175
+ fit = report.get("sramFit")
176
+ if not fit:
177
+ return []
178
+ lines = []
179
+ for key, label in _SRAM_FIT_AXIS_LABELS:
180
+ axis = fit[key]
181
+ verdict = axis["verdict"]
182
+ if verdict == "skipped":
183
+ lines.append(f" SRAM fit ({label}): skipped -- {axis['reason']}")
184
+ elif verdict == "no-fit":
185
+ lines.append(f" SRAM fit ({label}): NO-FIT -- needs {axis['neededKib']} KiB, "
186
+ f"only {axis['limitKib']} KiB available")
187
+ else:
188
+ caveat = f" ({axis['reason']})" if axis.get("reason") else ""
189
+ lines.append(f" SRAM fit ({label}): fits-unverified -- "
190
+ f"{axis['neededKib']} KiB <= {axis['limitKib']} KiB{caveat}")
191
+ return lines
192
+
193
+
194
+ def render_backend_report(report: dict, *, sku: str | None, prefix: str = "") -> list[str]:
195
+ """One backend's block of text-mode lines: the header (`{label} ({sku})
196
+ {coverage}`), the coverage + CPU-fallback summary lines, the SRAM-fit
197
+ lines (tan-cli#1288, absent when `sramFit` is `null`), every note (this
198
+ is where the engine's own silent-CPU-fallback sentence and the
199
+ uncosted-MAC caveat surface), and the exact-upgrade hint."""
200
+ label = backend_label(report["backend"], report["variant"])
201
+ lines = [f"{prefix}{label} ({sku}) {report['npuCoverage']}"]
202
+ for build_line in (_coverage_line, _cpu_fallback_line):
203
+ line = build_line(report)
204
+ if line:
205
+ lines.append(line)
206
+ lines.extend(_sram_fit_lines(report))
207
+ lines.extend(f" {note}" for note in report["notes"])
208
+ hint = _exact_hint_line(report)
209
+ if hint:
210
+ lines.append(hint)
211
+ return lines
212
+
213
+
214
+ def render_check_text(data: dict) -> list[str]:
215
+ """Every backend block for every declared model, in order. `[]` for no
216
+ declared models -- the caller (`model_cmd.finish`) is the one that
217
+ decides whether an empty result needs a "nothing to check" filler line,
218
+ the same split `build`'s own text branch already makes for `built`."""
219
+ models = data.get("models") or []
220
+ sku = data.get("sku")
221
+ show_name = len(models) > 1
222
+ lines: list[str] = []
223
+ for m in models:
224
+ prefix = f"{m['name']}: " if show_name else ""
225
+ for backend in m.get("backends", []):
226
+ lines.extend(render_backend_report(backend, sku=sku, prefix=prefix))
227
+ return lines
@@ -0,0 +1,449 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """On-device tier of `tan model run`/`ab` (tan-cli#1287): parse the console a
3
+ benchmark app printed on the target and turn it into a `RunResult` row.
4
+
5
+ Ported from alp-sdk `integration/model-edge-ai` (`scripts/alp_model/ondevice.py`,
6
+ alp-sdk#933), parser and math only, then hardened: the console protocol is the one
7
+ alp-sdk's `examples/aen/aen-inference-energy` prints (`ENERGY-CFG` / `ENERGY-S` /
8
+ `ENERGY-W` / `ENERGY-WERR` / `ENERGY-WARN` / `ENERGY-PAIR` / `ENERGY-RESULT`; that
9
+ app's `printk` calls are the source of truth). The reference's labgrid /
10
+ `build.sh` / `ram-run.sh` driver is NOT ported -- tan's own flash and monitor paths
11
+ own deployment and capture.
12
+
13
+ **Honest by construction.** A capture is untrusted text: every value is type- and
14
+ range-checked (finite, positive spans and clock, non-negative counts) and anything
15
+ else raises `DeviceCaptureError`, never a plausible number.
16
+
17
+ * `latencyMs` is the active window's measured span divided by the inferences it
18
+ completed. The span is wall time of the app's WHOLE window loop, so it is an
19
+ upper bound on pure inference time (the energy app also polls the monitor IC
20
+ inside it; the latency-only app does not) -- hence the app-neutral
21
+ `LATENCY_SCOPE`.
22
+ When the app also printed `LATENCY-RESULT`, its own `ms_per_inference` wins
23
+ (tan-cli#1404): the app derives it from its raw cycle counts over every good
24
+ window (all inferences, not a median of per-window spans), so it can differ
25
+ slightly from the window-derived figure; the device is the authority on its
26
+ own latency.
27
+ * `peakSramKib` is ENERGY-CFG `sram_peak_bytes` / 1024 when the app reports it,
28
+ else `None`. The model name is ENERGY-CFG `model`.
29
+ * `RunResult.power_mj` stays `None`; energy rides in `RunResult.energy`, a
30
+ labelled board-level carrier-rail delta (`EnergyMeasurement`), only when the
31
+ capture holds usable active+idle sample pairs. Pairs the app itself marked
32
+ `ENERGY-PAIR n SKIPPED` and windows that timed out are excluded from energy
33
+ (timed-out windows from latency too) and listed as degraded evidence.
34
+ * A capture with `ENERGY-W` lines but no `ENERGY-S` stream is a valid
35
+ latency-only capture.
36
+ * `ENERGY-S` cycle stamps are a uint32 counter: one wrap per window is handled
37
+ by the modular delta; a counter that steps BACKWARDS is indistinguishable from
38
+ a wrap, so non-monotonic samples are not detected (a limit of the protocol).
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import math
44
+ import re
45
+ from dataclasses import dataclass, field
46
+ from statistics import mean, median, stdev
47
+ from typing import Any
48
+
49
+ from tan.model.measure import EnergyMeasurement, RunResult, windowed_delta
50
+
51
+ #: +-5% agreement band between the ENERGY-W-measured clock and the DT constant.
52
+ _CYCLES_PER_S_TOLERANCE = 0.05
53
+
54
+ #: ENERGY-CFG `timestamp_source` whose spans share `cycles_per_s`'s own clock.
55
+ _KERNEL_CLOCK = "k-cycle-get-32"
56
+
57
+ #: What `latencyMs` is, carried in every device row.
58
+ LATENCY_SCOPE = "window-span-per-inference"
59
+
60
+ #: `latencyMs` when the app's own LATENCY-RESULT `ms_per_inference` is used instead.
61
+ #: That value is the app's, computed from the nominal `cycles_per_s`; it does NOT
62
+ #: go through tan's measured-clock reconciliation, and may time a different set of
63
+ #: inferences than the ENERGY-W active windows.
64
+ LATENCY_SCOPE_DEVICE_RESULT = "device-latency-result"
65
+
66
+ _U32 = 1 << 32
67
+ _WERR_RE = re.compile(r"^ENERGY-WERR (\d+) (active|idle) timed_out=(\d)")
68
+ _WARN_WRAP_RE = re.compile(r"^ENERGY-WARN (active|idle) window (\d+) ms exceeds the cycle-counter wrap")
69
+ _PAIR_SKIP_RE = re.compile(r"^ENERGY-PAIR (\d+) SKIPPED")
70
+
71
+ #: The app's own verdict that its energy delta is noise (tan-cli#1387).
72
+ _DEVICE_UNRESOLVED = "RESULT FAIL: delta not resolvable"
73
+
74
+ #: aen-inference-energy's `NOISE_SIGMA`: a pair-to-pair mean must clear this
75
+ #: many standard errors of the mean (`spread / sqrt(pairs)`) to be a result.
76
+ #: Used only when the capture carries no device verdict of its own.
77
+ NOISE_SIGMA = 3.0
78
+
79
+
80
+ class DeviceCaptureError(Exception):
81
+ """The console capture is missing pieces, malformed or from an older app."""
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ParsedCapture:
86
+ """A validated console capture. `samples` / `uptime_spans` are keyed
87
+ [window][phase] ("active"/"idle"); spans are `(n_samples, span_cycles,
88
+ span_ms, inferences)`. `werr`/`warn`/`pair_skips` keep the raw evidence
89
+ lines in capture order; `timed_out` is the (window, phase) set the app said
90
+ missed its deadline, `skipped` the windows it excluded itself, `wrapped`
91
+ the (window, phase) spans whose cycle count the app flagged as wrapped."""
92
+ cfg: dict[str, Any]
93
+ samples: dict[int, dict[str, list[tuple[int, int]]]]
94
+ uptime_spans: dict[int, dict[str, tuple[int, int, float, int]]]
95
+ device_result: dict[str, Any] | None
96
+ werr: list[str]
97
+ warn: list[str]
98
+ pair_skips: list[str] = field(default_factory=list)
99
+ timed_out: frozenset = frozenset()
100
+ skipped: frozenset = frozenset()
101
+ wrapped: frozenset = frozenset()
102
+ latency_result: dict[str, Any] | None = None
103
+ #: The app's `RESULT PASS: ... mJ/inference` or `RESULT FAIL: delta not
104
+ #: resolvable` line, whichever came last; `None` when it printed neither.
105
+ energy_verdict: str | None = None
106
+
107
+
108
+ def _json_object(text: str, what: str) -> dict[str, Any]:
109
+ try:
110
+ value = json.loads(text)
111
+ except (ValueError, RecursionError) as exc: # JSONDecodeError, int-digit limit, deep nesting
112
+ raise DeviceCaptureError(f"malformed {what} line") from exc
113
+ if not isinstance(value, dict):
114
+ raise DeviceCaptureError(f"{what} must be a JSON object, got {type(value).__name__}")
115
+ return value
116
+
117
+
118
+ def _number(value: Any, what: str, *, positive: bool = True) -> float:
119
+ """`value` as a finite float > 0 (or >= 0), else `DeviceCaptureError`."""
120
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
121
+ raise DeviceCaptureError(f"{what} must be a number, got {value!r}")
122
+ try:
123
+ number = float(value)
124
+ except OverflowError as exc:
125
+ raise DeviceCaptureError(f"{what} is out of range") from exc
126
+ if not math.isfinite(number) or number < 0 or (positive and number == 0):
127
+ raise DeviceCaptureError(f"{what} must be finite and {'> 0' if positive else '>= 0'}, got {value!r}")
128
+ return number
129
+
130
+
131
+ def _uint(token: str, what: str, line: str, *, limit: int | None = None) -> int:
132
+ try:
133
+ value = int(token)
134
+ except ValueError as exc:
135
+ raise DeviceCaptureError(f"malformed {what} in {line!r}") from exc
136
+ if value < 0 or (limit is not None and value >= limit):
137
+ raise DeviceCaptureError(f"{what} out of range in {line!r}")
138
+ return value
139
+
140
+
141
+ def _parse_w(parts: list[str], line: str) -> tuple[int, str, tuple[int, int, float, int]]:
142
+ if len(parts) == 6 and parts[2] in ("active", "idle"):
143
+ raise DeviceCaptureError(
144
+ f"ENERGY-W line has 5 fields, no per-window inference count: {line!r} -- "
145
+ "this capture is from an older firmware image that predates per-window "
146
+ "ENERGY-W inference reporting; re-flash the current aen-inference-energy build")
147
+ if len(parts) != 7 or parts[2] not in ("active", "idle"):
148
+ raise DeviceCaptureError(f"malformed ENERGY-W line: {line!r}")
149
+ window = _uint(parts[1], "window", line)
150
+ n_samples = _uint(parts[3], "sample count", line)
151
+ span_cycles = _uint(parts[4], "span_cycles", line, limit=_U32)
152
+ try:
153
+ span_ms = float(parts[5])
154
+ except ValueError as exc:
155
+ raise DeviceCaptureError(f"malformed span_ms in {line!r}") from exc
156
+ if not math.isfinite(span_ms) or span_ms <= 0 or span_cycles <= 0:
157
+ raise DeviceCaptureError(f"span must be finite and > 0 in {line!r}")
158
+ return window, parts[2], (n_samples, span_cycles, span_ms, _uint(parts[6], "inferences", line))
159
+
160
+
161
+ def parse_console(text: str) -> ParsedCapture:
162
+ """Tolerant of interleaved noise (Zephyr banner, other printk, ENERGY-SCAN);
163
+ strict about every recognised line. Raises `DeviceCaptureError` on a
164
+ missing/malformed/invalid header, a malformed or out-of-range line, a
165
+ duplicate ENERGY-W, a window/phase with fewer than 2 samples, an
166
+ old-firmware ENERGY-W, an ENERGY-WPART partial window, or no ENERGY-W at all."""
167
+ cfg: dict[str, Any] | None = None
168
+ samples: dict[int, dict[str, list[tuple[int, int]]]] = {}
169
+ spans: dict[int, dict[str, tuple[int, int, float, int]]] = {}
170
+ device_result: dict[str, Any] | None = None
171
+ latency_result: dict[str, Any] | None = None
172
+ werr: list[str] = []
173
+ warn: list[str] = []
174
+ pair_skips: list[str] = []
175
+ timed_out: set[tuple[int, str]] = set()
176
+ skipped: set[int] = set()
177
+ wrapped: set[tuple[int, str]] = set()
178
+ energy_verdict: str | None = None
179
+
180
+ for raw in text.splitlines():
181
+ line = raw.strip()
182
+ parts = line.split()
183
+ if line.startswith("ENERGY-CFG "):
184
+ cfg = _json_object(line[len("ENERGY-CFG "):], "ENERGY-CFG")
185
+ elif line.startswith("ENERGY-S "):
186
+ if len(parts) != 5 or parts[2] not in ("active", "idle"):
187
+ raise DeviceCaptureError(f"malformed ENERGY-S line: {line!r}")
188
+ window = _uint(parts[1], "window", line)
189
+ cycles = _uint(parts[3], "cycle stamp", line, limit=_U32)
190
+ power = _uint(parts[4], "power count", line, limit=_U32)
191
+ samples.setdefault(window, {}).setdefault(parts[2], []).append((cycles, power))
192
+ elif line.startswith("ENERGY-W "):
193
+ window, phase, span = _parse_w(parts, line)
194
+ if phase in spans.get(window, {}):
195
+ raise DeviceCaptureError(f"duplicate ENERGY-W for window {window} {phase}: {line!r}")
196
+ spans.setdefault(window, {})[phase] = span
197
+ elif line.startswith("ENERGY-WPART "):
198
+ raise DeviceCaptureError(
199
+ f"a window emitted a partial sample stream ({line!r}) -- the device integral "
200
+ "is authoritative for this build and `--device` cannot re-derive energy from "
201
+ "a partial window")
202
+ elif line.startswith("ENERGY-WERR "):
203
+ werr.append(line)
204
+ m = _WERR_RE.match(line)
205
+ if m and m.group(3) == "1":
206
+ timed_out.add((int(m.group(1)), m.group(2)))
207
+ elif line.startswith("ENERGY-WARN "):
208
+ warn.append(line)
209
+ m = _WARN_WRAP_RE.match(line)
210
+ if m:
211
+ wrapped.add((int(m.group(2)), m.group(1)))
212
+ elif line.startswith("LATENCY-WARN "):
213
+ warn.append(line)
214
+ elif line.startswith("RESULT ") and " WARN: " in line:
215
+ warn.append("WARN: " + line.split(" WARN: ", 1)[1])
216
+ elif line.startswith("RESULT FAIL:"):
217
+ warn.append(line)
218
+ if line.startswith(_DEVICE_UNRESOLVED):
219
+ energy_verdict = line
220
+ elif line.startswith("RESULT PASS:") and "mJ/inference" in line:
221
+ energy_verdict = line
222
+ elif line.startswith("ENERGY-PAIR "):
223
+ m = _PAIR_SKIP_RE.match(line)
224
+ if m:
225
+ pair_skips.append(line)
226
+ skipped.add(int(m.group(1)))
227
+ elif line.startswith("ENERGY-RESULT "):
228
+ device_result = _json_object(line[len("ENERGY-RESULT "):], "ENERGY-RESULT")
229
+ elif line.startswith("LATENCY-RESULT "):
230
+ latency_result = _json_object(line[len("LATENCY-RESULT "):], "LATENCY-RESULT")
231
+ # else: banner / printk noise (incl. ENERGY-SCAN) -- ignored.
232
+
233
+ if cfg is None:
234
+ raise DeviceCaptureError("no ENERGY-CFG header line found in capture")
235
+ _number(cfg.get("cycles_per_s"), "ENERGY-CFG cycles_per_s")
236
+ if "power_lsb_w" in cfg:
237
+ _number(cfg["power_lsb_w"], "ENERGY-CFG power_lsb_w")
238
+ if "rail" in cfg and not isinstance(cfg["rail"], str):
239
+ raise DeviceCaptureError("ENERGY-CFG rail must be a string")
240
+ if not spans:
241
+ raise DeviceCaptureError("no ENERGY-W lines found in capture")
242
+ for window, phases in samples.items():
243
+ for phase, points in phases.items():
244
+ if len(points) < 2:
245
+ raise DeviceCaptureError(f"window {window} phase {phase!r} has {len(points)} sample(s); need >= 2")
246
+ return ParsedCapture(cfg, samples, spans, device_result, werr, warn, pair_skips,
247
+ frozenset(timed_out), frozenset(skipped), frozenset(wrapped), latency_result,
248
+ energy_verdict)
249
+
250
+
251
+ def _samples_to_power(points: list[tuple[int, int]], cycles_per_s: float,
252
+ power_lsb_w: float) -> list[tuple[float, float]]:
253
+ """(cycles, power_raw) -> (t_seconds, power_watts); t is the unsigned delta
254
+ from the window's first stamp, mod 2**32 (one wrap per window is correct; see
255
+ the module docstring for the backwards-counter limit)."""
256
+ if not points:
257
+ return []
258
+ t0 = points[0][0]
259
+ return [(((cycles - t0) % _U32) / cycles_per_s, power * power_lsb_w) for cycles, power in points]
260
+
261
+
262
+ def _span_rates(parsed: ParsedCapture) -> list[float]:
263
+ """Cycles per second implied by every ENERGY-W span, unwrapped and not timed
264
+ out (a window the app reported ENERGY-WERR timed_out=1 for ends early, so its
265
+ span says nothing about the clock; latency already excludes it)."""
266
+ return [
267
+ span[1] / span[2] * 1000.0
268
+ for window, phases in parsed.uptime_spans.items()
269
+ for phase, span in phases.items()
270
+ if (window, phase) not in parsed.wrapped and (window, phase) not in parsed.timed_out
271
+ ]
272
+
273
+
274
+ def _cycles_per_s(parsed: ParsedCapture) -> tuple[float, float | None]:
275
+ """`(used, measured)`: the median span-implied clock when every span agrees
276
+ with the others within `_CYCLES_PER_S_TOLERANCE`, else the DT constant."""
277
+ dt = float(parsed.cfg["cycles_per_s"])
278
+ rates = _span_rates(parsed)
279
+ if not rates:
280
+ return dt, None
281
+ measured = median(rates)
282
+ if parsed.cfg.get("timestamp_source") == _KERNEL_CLOCK:
283
+ # Spans and `cycles_per_s` come from the SAME kernel clock, and the span's
284
+ # ms field is an integer tick, so span_cycles / span_ms is a rounding
285
+ # artefact, not a second opinion on the clock: use the constant, and
286
+ # report the ratio informationally only.
287
+ return dt, measured
288
+ if (max(rates) - min(rates)) / measured > _CYCLES_PER_S_TOLERANCE:
289
+ return dt, measured
290
+ return measured, measured
291
+
292
+
293
+ def _energy_windows(parsed: ParsedCapture) -> list[int]:
294
+ return [
295
+ w for w in sorted(parsed.samples)
296
+ if {"active", "idle"} <= set(parsed.samples[w])
297
+ and w not in parsed.skipped
298
+ and (w, "active") not in parsed.timed_out
299
+ and (w, "idle") not in parsed.timed_out
300
+ ]
301
+
302
+
303
+ def measurement_from_capture(parsed: ParsedCapture) -> EnergyMeasurement:
304
+ """Per usable (active, idle) pair: `windowed_delta` over that pair's own
305
+ active inference count (ENERGY-W's last field). Excludes pairs the app
306
+ marked SKIPPED and windows that timed out. `value` is the mean across pairs,
307
+ `spread_mj` the sample stdev (None for one pair)."""
308
+ cfg = parsed.cfg
309
+ if not isinstance(cfg.get("rail"), str) or not cfg["rail"]:
310
+ raise DeviceCaptureError("ENERGY-CFG rail must be a non-empty string to derive energy")
311
+ power_lsb_w = _number(cfg.get("power_lsb_w"), "ENERGY-CFG power_lsb_w")
312
+ cycles_per_s, _ = _cycles_per_s(parsed)
313
+ windows = _energy_windows(parsed)
314
+ if not windows:
315
+ raise DeviceCaptureError("no usable (active, idle) sample pair (skipped, timed-out or incomplete)")
316
+ per_window, window_ms, total_samples, total_inferences = [], [], 0, 0
317
+ for w in windows:
318
+ active_span = parsed.uptime_spans.get(w, {}).get("active")
319
+ if active_span is None:
320
+ raise DeviceCaptureError(f"window {w} has no ENERGY-W line for phase 'active'")
321
+ n_inferences = active_span[3]
322
+ if n_inferences < 1:
323
+ raise DeviceCaptureError(f"window {w} active phase completed 0 inferences; no mJ/inference divisor")
324
+ active = _samples_to_power(parsed.samples[w]["active"], cycles_per_s, power_lsb_w)
325
+ idle = _samples_to_power(parsed.samples[w]["idle"], cycles_per_s, power_lsb_w)
326
+ per_window.append(windowed_delta(active, idle, n_inferences))
327
+ window_ms.append((active[-1][0] - active[0][0]) * 1000.0)
328
+ total_samples += len(active) + len(idle)
329
+ total_inferences += n_inferences
330
+ return EnergyMeasurement(
331
+ value_mj_per_inference=per_window[0] if len(per_window) == 1 else mean(per_window),
332
+ rails=[cfg["rail"]],
333
+ n_inferences=total_inferences,
334
+ window_ms=mean(window_ms),
335
+ sample_count=total_samples,
336
+ spread_mj=None if len(per_window) < 2 else stdev(per_window),
337
+ )
338
+
339
+
340
+ def capture_diagnostics(parsed: ParsedCapture, energy: EnergyMeasurement | None) -> dict:
341
+ """Everything `EnergyMeasurement` can't carry (camelCase, envelope-ready):
342
+ the clock reconciliation, the device's own result as a cross-check against
343
+ the ALREADY-computed `energy` (never recomputed here), and the degraded-run
344
+ evidence."""
345
+ used, measured = _cycles_per_s(parsed)
346
+ device_value = None
347
+ if parsed.device_result is not None:
348
+ raw = parsed.device_result.get("value_mj_per_inference")
349
+ device_value = float(raw) if isinstance(raw, (int, float)) and not isinstance(raw, bool) and math.isfinite(raw) else None
350
+ ratio = round(energy.value_mj_per_inference / device_value, 4) if energy and device_value else None
351
+ return {
352
+ "cyclesPerSUsed": used,
353
+ "cyclesPerSDt": float(parsed.cfg["cycles_per_s"]),
354
+ "cyclesPerSMeasured": measured,
355
+ "deviceValueMjPerInference": device_value,
356
+ "hostVsDeviceRatio": ratio,
357
+ "model": parsed.cfg.get("model") if isinstance(parsed.cfg.get("model"), str) else None,
358
+ "npuDispatched": bool(parsed.cfg.get("npu_dispatched", False)),
359
+ "windows": sorted(parsed.uptime_spans),
360
+ "werrLines": list(parsed.werr),
361
+ "warnLines": list(parsed.warn),
362
+ "skippedPairs": list(parsed.pair_skips),
363
+ "energyWindows": _energy_windows(parsed),
364
+ }
365
+
366
+
367
+ def latency_from_capture(parsed: ParsedCapture) -> tuple[float, int, float]:
368
+ """`(median ms per inference, total active inferences, median cycles per
369
+ inference)` from the active ENERGY-W spans of windows that did not time out.
370
+ Derived from `span_cycles` over the reconciled clock (the span's own
371
+ millisecond field for a window the app flagged as counter-wrapped), rounded
372
+ to what the clock can resolve. Raises when nothing completed an inference."""
373
+ used, _ = _cycles_per_s(parsed)
374
+ per_ms: list[float] = []
375
+ per_cycles: list[float] = []
376
+ total = 0
377
+ for window, phases in parsed.uptime_spans.items():
378
+ span = phases.get("active")
379
+ if span is None or span[3] < 1 or (window, "active") in parsed.timed_out:
380
+ continue
381
+ _n, span_cycles, span_ms, inferences = span
382
+ wrapped = (window, "active") in parsed.wrapped
383
+ per_ms.append((span_ms if wrapped else span_cycles / used * 1000.0) / inferences)
384
+ per_cycles.append(span_cycles / inferences)
385
+ total += inferences
386
+ if not per_ms:
387
+ raise DeviceCaptureError("no active window completed an inference in time; nothing to time")
388
+ resolution_ms = 1000.0 / (used * total)
389
+ return round(median(per_ms), max(0, math.ceil(-math.log10(resolution_ms)))), total, median(per_cycles)
390
+
391
+
392
+ def _unresolved_energy(parsed: ParsedCapture, energy: EnergyMeasurement | None) -> dict | None:
393
+ """tan-cli#1387: the measurement, kept for diagnostics, when its delta is
394
+ noise; `None` when it is a result (or there is none). The app's own verdict
395
+ line wins when it printed one; otherwise the app's rule is applied here:
396
+ with >= 2 pairs the mean must exceed `NOISE_SIGMA` standard errors of the
397
+ mean, with one pair it must be > 0."""
398
+ if energy is None:
399
+ return None
400
+ pairs = len(_energy_windows(parsed))
401
+ value, spread = energy.value_mj_per_inference, energy.spread_mj
402
+ verdict = parsed.energy_verdict
403
+ if verdict is not None:
404
+ if not verdict.startswith(_DEVICE_UNRESOLVED):
405
+ return None
406
+ source = "device"
407
+ elif value > (NOISE_SIGMA * spread / math.sqrt(pairs) if spread is not None else 0.0):
408
+ return None
409
+ else:
410
+ source = "host"
411
+ return {
412
+ "valueMjPerInference": value, "spreadMj": spread, "rail": energy.rails[0],
413
+ "pairs": pairs, "verdict": source, "deviceLine": verdict,
414
+ }
415
+
416
+
417
+ def run_result_from_capture(parsed: ParsedCapture) -> tuple[RunResult, EnergyMeasurement | None, dict]:
418
+ """The `RunResult` (tier-device schema), the labelled energy measurement when
419
+ the capture supports one (else `None`, with the reason in
420
+ `diagnostics["energyNote"]` when samples existed), and the diagnostics.
421
+ Energy is computed exactly once."""
422
+ latency_ms, inferences, _ = latency_from_capture(parsed)
423
+ energy: EnergyMeasurement | None = None
424
+ note: str | None = None
425
+ if parsed.samples:
426
+ try:
427
+ energy = measurement_from_capture(parsed)
428
+ except (DeviceCaptureError, ArithmeticError) as err:
429
+ note = f"energy not derived: {err}"
430
+ unresolved = _unresolved_energy(parsed, energy)
431
+ if unresolved is not None:
432
+ energy = None
433
+ diagnostics = capture_diagnostics(parsed, energy)
434
+ diagnostics["energyNote"] = note
435
+ diagnostics["unresolvedEnergy"] = unresolved
436
+ diagnostics["windowLatencyMs"] = latency_ms
437
+ diagnostics["latencyScope"] = LATENCY_SCOPE
438
+ if parsed.latency_result is not None and "ms_per_inference" in parsed.latency_result:
439
+ # Device-reported: nominal cycles_per_s, no measured-clock check (see
440
+ # LATENCY_SCOPE_DEVICE_RESULT); the window-derived value stays in diagnostics.
441
+ latency_ms = _number(parsed.latency_result["ms_per_inference"], "LATENCY-RESULT ms_per_inference")
442
+ diagnostics["latencyScope"] = LATENCY_SCOPE_DEVICE_RESULT
443
+ peak_sram_kib = None
444
+ if "sram_peak_bytes" in parsed.cfg:
445
+ peak_sram_kib = _number(parsed.cfg["sram_peak_bytes"], "ENERGY-CFG sram_peak_bytes",
446
+ positive=False) / 1024
447
+ backend = "ethos-u" if parsed.cfg.get("npu_dispatched") else "cpu-device"
448
+ result = RunResult(backend, latency_ms, None, peak_sram_kib, None, inferences, energy)
449
+ return result, energy, diagnostics