ldcon 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.
Files changed (482) hide show
  1. ldcon/__init__.py +302 -0
  2. ldcon/_moved.py +49 -0
  3. ldcon/agent_tools.py +4 -0
  4. ldcon/agents/__init__.py +1 -0
  5. ldcon/agents/adjustment_guidance.py +73 -0
  6. ldcon/agents/adjustment_quality.py +399 -0
  7. ldcon/agents/agent_adapters.py +709 -0
  8. ldcon/agents/agent_background.py +307 -0
  9. ldcon/agents/agent_bundle.py +75 -0
  10. ldcon/agents/agent_cards.py +171 -0
  11. ldcon/agents/agent_config_access.py +22 -0
  12. ldcon/agents/agent_configuration.py +224 -0
  13. ldcon/agents/agent_configuration_policy.py +67 -0
  14. ldcon/agents/agent_connect.py +93 -0
  15. ldcon/agents/agent_device_parameters.py +41 -0
  16. ldcon/agents/agent_execution.py +77 -0
  17. ldcon/agents/agent_history.py +112 -0
  18. ldcon/agents/agent_inbox_watch.py +121 -0
  19. ldcon/agents/agent_interactive.py +386 -0
  20. ldcon/agents/agent_interactive_hooks.py +381 -0
  21. ldcon/agents/agent_language.py +48 -0
  22. ldcon/agents/agent_log_context.py +308 -0
  23. ldcon/agents/agent_log_read.py +250 -0
  24. ldcon/agents/agent_log_watch.py +27 -0
  25. ldcon/agents/agent_metrics.py +111 -0
  26. ldcon/agents/agent_monitor.py +272 -0
  27. ldcon/agents/agent_opencode_http.py +270 -0
  28. ldcon/agents/agent_opencode_mcp.py +64 -0
  29. ldcon/agents/agent_owner_status.py +216 -0
  30. ldcon/agents/agent_profiles.py +186 -0
  31. ldcon/agents/agent_providers.py +53 -0
  32. ldcon/agents/agent_repairs.py +105 -0
  33. ldcon/agents/agent_routing.py +90 -0
  34. ldcon/agents/agent_runtime.py +70 -0
  35. ldcon/agents/agent_sandbox.py +141 -0
  36. ldcon/agents/agent_startup_notice.py +142 -0
  37. ldcon/agents/agent_task.py +336 -0
  38. ldcon/agents/agent_tools.py +488 -0
  39. ldcon/agents/agent_window.py +70 -0
  40. ldcon/agents/agent_workspace.py +340 -0
  41. ldcon/agents/card_fallback.py +284 -0
  42. ldcon/agents/dlc_pi_adjustment.py +250 -0
  43. ldcon/agents/dlc_pi_plan.py +24 -0
  44. ldcon/agents/dlc_pi_response_plan.py +26 -0
  45. ldcon/agents/failure_policy.py +473 -0
  46. ldcon/agents/fallback_service.py +146 -0
  47. ldcon/agents/fallback_targets.py +96 -0
  48. ldcon/agents/gateway_role.py +31 -0
  49. ldcon/agents/manual_adjustment_grant.py +66 -0
  50. ldcon/agents/measurement_grant.py +95 -0
  51. ldcon/agents/monitor_quality_binding.py +33 -0
  52. ldcon/agents/notification_policy.py +346 -0
  53. ldcon/agents/operating_mode_client.py +99 -0
  54. ldcon/agents/report_pdf.py +105 -0
  55. ldcon/agents/resident_adjustment.py +572 -0
  56. ldcon/agents/resident_agent.py +438 -0
  57. ldcon/agents/resident_quality_apply.py +233 -0
  58. ldcon/agents/task_decisions.py +218 -0
  59. ldcon/agents/teams_card.py +100 -0
  60. ldcon/agents/teams_destinations.py +157 -0
  61. ldcon/cld_owner.py +4 -0
  62. ldcon/cli.py +19 -0
  63. ldcon/core/__init__.py +1 -0
  64. ldcon/core/adjustment_plan.py +47 -0
  65. ldcon/core/adjustment_registry.py +100 -0
  66. ldcon/core/agent_authority.py +903 -0
  67. ldcon/core/card_admission.py +15 -0
  68. ldcon/core/card_definition.py +14 -0
  69. ldcon/core/card_reporting.py +238 -0
  70. ldcon/core/configuration.py +357 -0
  71. ldcon/core/configuration_transaction.py +225 -0
  72. ldcon/core/contracts.py +86 -0
  73. ldcon/core/current_settings.py +86 -0
  74. ldcon/core/deck_yaml.py +45 -0
  75. ldcon/core/device_conditions.py +133 -0
  76. ldcon/core/execution_context.py +3 -0
  77. ldcon/core/instrument_client.py +190 -0
  78. ldcon/core/interface_cards.py +520 -0
  79. ldcon/core/interface_schema.py +640 -0
  80. ldcon/core/laser_assets.py +238 -0
  81. ldcon/core/metric_quantities.py +38 -0
  82. ldcon/core/modulation.py +107 -0
  83. ldcon/core/operation_registry.py +195 -0
  84. ldcon/core/paths.py +26 -0
  85. ldcon/core/period_context.py +118 -0
  86. ldcon/core/records.py +4 -0
  87. ldcon/core/result_reservations.py +469 -0
  88. ldcon/core/site_bindings.py +25 -0
  89. ldcon/core/state_layout.py +70 -0
  90. ldcon/core/target_policy.py +79 -0
  91. ldcon/core/telegraf.py +106 -0
  92. ldcon/core/telemetry.py +130 -0
  93. ldcon/core/temperature_settings.py +12 -0
  94. ldcon/dashboard_metrics.py +4 -0
  95. ldcon/device_registry.py +4 -0
  96. ldcon/drivers/__init__.py +1 -0
  97. ldcon/drivers/cld1015.py +296 -0
  98. ldcon/drivers/cld1015_config.py +31 -0
  99. ldcon/drivers/dlcpro.py +177 -0
  100. ldcon/drivers/dlcpro_analog.py +33 -0
  101. ldcon/drivers/dlcpro_console.py +133 -0
  102. ldcon/drivers/dlcpro_priority.py +46 -0
  103. ldcon/drivers/ecdl.py +334 -0
  104. ldcon/drivers/esp32_servo.py +187 -0
  105. ldcon/drivers/fizeau.py +114 -0
  106. ldcon/drivers/frequency_sweep.py +69 -0
  107. ldcon/drivers/influx_wavelength.py +70 -0
  108. ldcon/drivers/metric_sources.py +82 -0
  109. ldcon/drivers/native_dither_telemetry.py +50 -0
  110. ldcon/drivers/rigol_scope.py +126 -0
  111. ldcon/drivers/siglent.py +418 -0
  112. ldcon/drivers/staircase.py +155 -0
  113. ldcon/drivers/wsu_udp.py +164 -0
  114. ldcon/esp32_time_telemetry.py +4 -0
  115. ldcon/example_config.py +41 -0
  116. ldcon/fallback_service.py +4 -0
  117. ldcon/instrument_owner.py +4 -0
  118. ldcon/instrument_service.py +4 -0
  119. ldcon/interface_worker.py +4 -0
  120. ldcon/job_service.py +4 -0
  121. ldcon/jobs/__init__.py +1 -0
  122. ldcon/jobs/adaptive_pid.py +234 -0
  123. ldcon/jobs/adaptive_window_session.py +922 -0
  124. ldcon/jobs/adjustment_continuation.py +284 -0
  125. ldcon/jobs/axis_frequency.py +219 -0
  126. ldcon/jobs/axis_frequency_report.py +86 -0
  127. ldcon/jobs/background_parameter_session.py +142 -0
  128. ldcon/jobs/background_pid_session.py +447 -0
  129. ldcon/jobs/branch_maps.py +71 -0
  130. ldcon/jobs/branch_target_migration.py +33 -0
  131. ldcon/jobs/card_runtime.py +157 -0
  132. ldcon/jobs/change_measure.py +293 -0
  133. ldcon/jobs/change_measure_adapter.py +360 -0
  134. ldcon/jobs/change_measure_dlc.py +94 -0
  135. ldcon/jobs/change_measure_parameters.py +222 -0
  136. ldcon/jobs/change_measure_target.py +135 -0
  137. ldcon/jobs/current_cards.py +159 -0
  138. ldcon/jobs/current_devices.py +71 -0
  139. ldcon/jobs/dashboard_api.py +61 -0
  140. ldcon/jobs/device_parameters.py +426 -0
  141. ldcon/jobs/dlc_pi_measurement.py +151 -0
  142. ldcon/jobs/dlcpro_awg_analysis.py +77 -0
  143. ldcon/jobs/dlcpro_awg_card.py +116 -0
  144. ldcon/jobs/dlcpro_cards.py +240 -0
  145. ldcon/jobs/ecdl_branch_hop.py +175 -0
  146. ldcon/jobs/ecdl_branch_recovery.py +176 -0
  147. ldcon/jobs/ecdl_devices.py +306 -0
  148. ldcon/jobs/ecdl_locked_sweep_proxy.py +526 -0
  149. ldcon/jobs/ecdl_mapped_recovery.py +6 -0
  150. ldcon/jobs/ecdl_mode_map.py +469 -0
  151. ldcon/jobs/ecdl_mode_recovery.py +20 -0
  152. ldcon/jobs/ecdl_online.py +158 -0
  153. ldcon/jobs/ecdl_operating_point.py +403 -0
  154. ldcon/jobs/ecdl_pi_commission.py +161 -0
  155. ldcon/jobs/ecdl_recovery.py +25 -0
  156. ldcon/jobs/ecdl_recovery_plan.py +90 -0
  157. ldcon/jobs/ecdl_recovery_test.py +59 -0
  158. ldcon/jobs/ecdl_relock.py +7 -0
  159. ldcon/jobs/ecdl_response_study.py +369 -0
  160. ldcon/jobs/ecdl_steady.py +343 -0
  161. ldcon/jobs/ecdl_thermal_context.py +242 -0
  162. ldcon/jobs/ecdl_window_center.py +734 -0
  163. ldcon/jobs/emission_card.py +102 -0
  164. ldcon/jobs/fringe_sweep_reference.py +89 -0
  165. ldcon/jobs/interface_devices.py +122 -0
  166. ldcon/jobs/interface_worker.py +471 -0
  167. ldcon/jobs/job_service.py +125 -0
  168. ldcon/jobs/laser_live.py +270 -0
  169. ldcon/jobs/learned_settling.py +450 -0
  170. ldcon/jobs/lock_target.py +461 -0
  171. ldcon/jobs/locked_triangle.py +36 -0
  172. ldcon/jobs/measurement_acquisition.py +51 -0
  173. ldcon/jobs/measurement_analysis.py +59 -0
  174. ldcon/jobs/measurement_execution.py +40 -0
  175. ldcon/jobs/measurement_notice.py +78 -0
  176. ldcon/jobs/measurement_presets.py +52 -0
  177. ldcon/jobs/measurement_report.py +76 -0
  178. ldcon/jobs/measurement_statistics.py +22 -0
  179. ldcon/jobs/metric_cards.py +34 -0
  180. ldcon/jobs/metric_interface.py +100 -0
  181. ldcon/jobs/modulation_sweep.py +274 -0
  182. ldcon/jobs/native_background_session.py +91 -0
  183. ldcon/jobs/native_current_acquire.py +143 -0
  184. ldcon/jobs/native_current_card.py +328 -0
  185. ldcon/jobs/native_current_online.py +129 -0
  186. ldcon/jobs/native_ff_proxy.py +1361 -0
  187. ldcon/jobs/native_ff_sweep.py +802 -0
  188. ldcon/jobs/online_pid.py +178 -0
  189. ldcon/jobs/operating_point.py +1399 -0
  190. ldcon/jobs/operating_point_proxy.py +236 -0
  191. ldcon/jobs/operation_execution.py +126 -0
  192. ldcon/jobs/operation_history.py +419 -0
  193. ldcon/jobs/optical_broker.py +23 -0
  194. ldcon/jobs/pi_algorithms.py +30 -0
  195. ldcon/jobs/pi_response.py +239 -0
  196. ldcon/jobs/progress.py +63 -0
  197. ldcon/jobs/quality_baseline.py +411 -0
  198. ldcon/jobs/recovery_decisions.py +131 -0
  199. ldcon/jobs/recovery_history.py +152 -0
  200. ldcon/jobs/recovery_procedure.py +285 -0
  201. ldcon/jobs/recovery_reference.py +123 -0
  202. ldcon/jobs/recovery_stationary.py +138 -0
  203. ldcon/jobs/response_algorithms.py +47 -0
  204. ldcon/jobs/response_comparison.py +62 -0
  205. ldcon/jobs/response_record.py +90 -0
  206. ldcon/jobs/runtime_parameters.py +434 -0
  207. ldcon/jobs/settling_profile.py +140 -0
  208. ldcon/jobs/shot_analysis.py +1300 -0
  209. ldcon/jobs/simulation.py +127 -0
  210. ldcon/jobs/slow_control.py +208 -0
  211. ldcon/jobs/slow_current.py +88 -0
  212. ldcon/jobs/study_adoption.py +122 -0
  213. ldcon/jobs/study_execution.py +182 -0
  214. ldcon/jobs/sweep_ff_analysis.py +320 -0
  215. ldcon/jobs/sweep_ff_optimization.py +202 -0
  216. ldcon/jobs/sweep_ff_plan.py +166 -0
  217. ldcon/jobs/sweep_ff_report.py +254 -0
  218. ldcon/jobs/sweep_ff_transport.py +326 -0
  219. ldcon/jobs/sweep_tracking.py +424 -0
  220. ldcon/jobs/sweep_verdict.py +288 -0
  221. ldcon/jobs/target_response.py +638 -0
  222. ldcon/jobs/target_response_analysis.py +65 -0
  223. ldcon/jobs/target_response_control.py +215 -0
  224. ldcon/jobs/target_response_decisions.py +152 -0
  225. ldcon/jobs/target_response_experiment.py +110 -0
  226. ldcon/jobs/target_response_feedforward.py +130 -0
  227. ldcon/jobs/target_response_search.py +225 -0
  228. ldcon/jobs/target_response_shift.py +475 -0
  229. ldcon/jobs/target_response_thermal.py +113 -0
  230. ldcon/jobs/target_response_thermal_experiment.py +120 -0
  231. ldcon/jobs/target_sequence.py +166 -0
  232. ldcon/jobs/temperature_identification.py +86 -0
  233. ldcon/jobs/wavelength_history.py +17 -0
  234. ldcon/jobs/window_actions.py +641 -0
  235. ldcon/jobs/window_center.py +784 -0
  236. ldcon/jobs/window_control.py +130 -0
  237. ldcon/jobs/window_decisions.py +105 -0
  238. ldcon/jobs/window_sampling.py +66 -0
  239. ldcon/jobs/workflow_artifacts.py +73 -0
  240. ldcon/migration/__init__.py +810 -0
  241. ldcon/migration/migration_decks.py +239 -0
  242. ldcon/migration/migration_readiness.py +397 -0
  243. ldcon/migration/migration_relocation.py +283 -0
  244. ldcon/monitor_owner.py +4 -0
  245. ldcon/owner_telemetry.py +4 -0
  246. ldcon/owners/__init__.py +1 -0
  247. ldcon/owners/adc_log_threshold.py +209 -0
  248. ldcon/owners/background_grants.py +44 -0
  249. ldcon/owners/cld_instrument_owner.py +96 -0
  250. ldcon/owners/cld_owner.py +706 -0
  251. ldcon/owners/cld_projection.py +194 -0
  252. ldcon/owners/cld_system_owner.py +276 -0
  253. ldcon/owners/current_owner.py +68 -0
  254. ldcon/owners/device_registry.py +229 -0
  255. ldcon/owners/dlcpro_awg_plan.py +115 -0
  256. ldcon/owners/dlcpro_awg_port.py +98 -0
  257. ldcon/owners/dlcpro_awg_protocol.py +190 -0
  258. ldcon/owners/dlcpro_awg_return.py +63 -0
  259. ldcon/owners/dlcpro_awg_sweep.py +437 -0
  260. ldcon/owners/dlcpro_awg_timing.py +35 -0
  261. ldcon/owners/dlcpro_awg_tracking.py +175 -0
  262. ldcon/owners/dlcpro_awg_tracking_guard.py +87 -0
  263. ldcon/owners/dlcpro_awg_tracking_report.py +49 -0
  264. ldcon/owners/dlcpro_awg_zero.py +82 -0
  265. ldcon/owners/dlcpro_deployment.py +66 -0
  266. ldcon/owners/dlcpro_ff.py +64 -0
  267. ldcon/owners/dlcpro_ff_adjustment.py +159 -0
  268. ldcon/owners/dlcpro_ff_delta_adjustment.py +194 -0
  269. ldcon/owners/dlcpro_frequency_window.py +61 -0
  270. ldcon/owners/dlcpro_observation.py +62 -0
  271. ldcon/owners/dlcpro_owner.py +830 -0
  272. ldcon/owners/dlcpro_pi_protocol.py +44 -0
  273. ldcon/owners/dlcpro_pi_trial.py +161 -0
  274. ldcon/owners/dlcpro_readback.py +50 -0
  275. ldcon/owners/dlcpro_service.py +203 -0
  276. ldcon/owners/dlcpro_target_changes.py +330 -0
  277. ldcon/owners/dlcpro_target_step.py +44 -0
  278. ldcon/owners/dlcpro_target_store.py +75 -0
  279. ldcon/owners/dlcpro_telemetry.py +72 -0
  280. ldcon/owners/ecdl_background_parameters.py +248 -0
  281. ldcon/owners/ecdl_background_pid.py +184 -0
  282. ldcon/owners/ecdl_hot_control.py +255 -0
  283. ldcon/owners/ecdl_independent_pid.py +95 -0
  284. ldcon/owners/ecdl_locked_study.py +122 -0
  285. ldcon/owners/ecdl_locked_sweep.py +1875 -0
  286. ldcon/owners/ecdl_native_pid.py +181 -0
  287. ldcon/owners/ecdl_optical.py +37 -0
  288. ldcon/owners/ecdl_owner.py +1058 -0
  289. ldcon/owners/ecdl_policy_reload.py +494 -0
  290. ldcon/owners/ecdl_prelock.py +612 -0
  291. ldcon/owners/ecdl_projection.py +636 -0
  292. ldcon/owners/ecdl_steady_owner.py +1859 -0
  293. ldcon/owners/ecdl_steady_policy.py +212 -0
  294. ldcon/owners/ecdl_target_ff.py +214 -0
  295. ldcon/owners/ecdl_target_settling.py +163 -0
  296. ldcon/owners/ecdl_tec_pair.py +88 -0
  297. ldcon/owners/ecdl_thermal_study.py +251 -0
  298. ldcon/owners/emission.py +365 -0
  299. ldcon/owners/emission_adapters.py +268 -0
  300. ldcon/owners/esp32_native_client.py +335 -0
  301. ldcon/owners/esp32_native_owner.py +2166 -0
  302. ldcon/owners/esp32_time_telemetry.py +253 -0
  303. ldcon/owners/esp32_udp_gate.py +136 -0
  304. ldcon/owners/external_sweep_guard.py +44 -0
  305. ldcon/owners/flexible_native_trial.py +172 -0
  306. ldcon/owners/fringe_peaks.py +92 -0
  307. ldcon/owners/full_data_log.py +133 -0
  308. ldcon/owners/instrument_identity.py +125 -0
  309. ldcon/owners/instrument_owner.py +1041 -0
  310. ldcon/owners/instrument_projection.py +104 -0
  311. ldcon/owners/instrument_service.py +638 -0
  312. ldcon/owners/migration_physical_lock.py +162 -0
  313. ldcon/owners/monitor_owner.py +322 -0
  314. ldcon/owners/native_assembly.py +37 -0
  315. ldcon/owners/native_assembly_host.py +260 -0
  316. ldcon/owners/native_background.py +233 -0
  317. ldcon/owners/native_current_tuning.py +251 -0
  318. ldcon/owners/native_ff_proof.py +433 -0
  319. ldcon/owners/native_instrument_owner.py +161 -0
  320. ldcon/owners/native_modulation_limits.py +45 -0
  321. ldcon/owners/native_policy_reload.py +186 -0
  322. ldcon/owners/native_policy_safety.py +213 -0
  323. ldcon/owners/native_policy_update.py +473 -0
  324. ldcon/owners/native_system_owner.py +420 -0
  325. ldcon/owners/operating_mode_owner.py +128 -0
  326. ldcon/owners/owner_projection.py +530 -0
  327. ldcon/owners/owner_telemetry.py +166 -0
  328. ldcon/owners/pi_handover.py +154 -0
  329. ldcon/owners/projection_transport.py +143 -0
  330. ldcon/owners/remote_instruments.py +323 -0
  331. ldcon/owners/sdg_client.py +122 -0
  332. ldcon/owners/sdg_owner.py +468 -0
  333. ldcon/owners/tec_rate.py +29 -0
  334. ldcon/owners/wsu_udp_monitor.py +113 -0
  335. ldcon/report_pdf.py +4 -0
  336. ldcon/resources/agent-model-options.json +142 -0
  337. ldcon/resources/agent_contract/adjustment.txt +45 -0
  338. ldcon/resources/agent_contract/common.txt +42 -0
  339. ldcon/resources/agent_contract/diagnosis.txt +11 -0
  340. ldcon/resources/agent_contract/research.txt +20 -0
  341. ldcon/resources/agent_contract/response_optimization.txt +13 -0
  342. ldcon/resources/examples/README.html +10 -0
  343. ldcon/resources/examples/assignments/sr1092.yaml +100 -0
  344. ldcon/resources/examples/assignments/sr422.yaml +677 -0
  345. ldcon/resources/examples/assignments/thf685.yaml +7 -0
  346. ldcon/resources/examples/interface-decks/dfb-dbr-ldc-tec.yaml +430 -0
  347. ldcon/resources/examples/interface-decks/ecdl-dlcpro.yaml +288 -0
  348. ldcon/resources/examples/interface-decks/ecdl-pzt-ldc-tec.yaml +698 -0
  349. ldcon/resources/examples/lasers/sr1092.yaml +267 -0
  350. ldcon/resources/examples/lasers/sr422.yaml +403 -0
  351. ldcon/resources/examples/lasers/thf685.yaml +158 -0
  352. ldcon/resources/report_template/ldconreport.cls +42 -0
  353. ldcon/resources/report_template/report.tex +47 -0
  354. ldcon/web/__init__.py +1 -0
  355. ldcon/web/agent_approval_api.py +126 -0
  356. ldcon/web/agent_settings_api.py +54 -0
  357. ldcon/web/agent_task_api.py +211 -0
  358. ldcon/web/control_readback.py +63 -0
  359. ldcon/web/dashboard_activity.py +109 -0
  360. ldcon/web/dashboard_controls_revision.py +29 -0
  361. ldcon/web/dashboard_metrics.py +146 -0
  362. ldcon/web/dashboard_snapshot.py +163 -0
  363. ldcon/web/dashboard_state.py +104 -0
  364. ldcon/web/dashboard_wavelength.py +150 -0
  365. ldcon/web/device_parameters_api.py +52 -0
  366. ldcon/web/dither_quality.py +46 -0
  367. ldcon/web/dlcpro_target_api.py +91 -0
  368. ldcon/web/dlcpro_target_query.py +23 -0
  369. ldcon/web/failure_api.py +62 -0
  370. ldcon/web/gateway_lan.py +208 -0
  371. ldcon/web/grafana_activity.py +84 -0
  372. ldcon/web/grafana_dashboard.py +492 -0
  373. ldcon/web/grafana_dlcpro.py +158 -0
  374. ldcon/web/grafana_quality.py +167 -0
  375. ldcon/web/interface_projection.py +27 -0
  376. ldcon/web/job_registry.py +68 -0
  377. ldcon/web/job_report.py +122 -0
  378. ldcon/web/laser_operations.py +445 -0
  379. ldcon/web/lock_target_api.py +554 -0
  380. ldcon/web/lock_target_queue.py +212 -0
  381. ldcon/web/log_archive.py +191 -0
  382. ldcon/web/metric_query_profiles.py +378 -0
  383. ldcon/web/online_position.py +26 -0
  384. ldcon/web/operating_mode_api.py +72 -0
  385. ldcon/web/operation_api.py +34 -0
  386. ldcon/web/operation_observation.py +57 -0
  387. ldcon/web/operations_settings.py +92 -0
  388. ldcon/web/quality_threshold_metrics.py +64 -0
  389. ldcon/web/shot_analysis_api.py +136 -0
  390. ldcon/web/sweep_api.py +1186 -0
  391. ldcon/web/sweep_guard.py +45 -0
  392. ldcon/web/target_reference_panel.py +38 -0
  393. ldcon/web/teams_destinations_api.py +81 -0
  394. ldcon/web/web_fleet.py +482 -0
  395. ldcon/web/web_runtime.py +811 -0
  396. ldcon/web/web_server.py +402 -0
  397. ldcon/web/window_api.py +28 -0
  398. ldcon/web_assets/agent-settings.css +4 -0
  399. ldcon/web_assets/agent-settings.html +53 -0
  400. ldcon/web_assets/agent-settings.js +270 -0
  401. ldcon/web_assets/app.css +9 -0
  402. ldcon/web_assets/app.js +802 -0
  403. ldcon/web_assets/card-browser.js +160 -0
  404. ldcon/web_assets/card-guide.js +9 -0
  405. ldcon/web_assets/cards/acquire.metrics.html +82 -0
  406. ldcon/web_assets/cards/agent.history_review.html +13 -0
  407. ldcon/web_assets/cards/agent.log_watch.html +1 -0
  408. ldcon/web_assets/cards/agent.monitor.html +13 -0
  409. ldcon/web_assets/cards/agent.request.html +1 -0
  410. ldcon/web_assets/cards/agent.resident.html +1 -0
  411. ldcon/web_assets/cards/agent.task.html +17 -0
  412. ldcon/web_assets/cards/commission.ecdl_pi.html +1 -0
  413. ldcon/web_assets/cards/identify.axis_frequency.html +1 -0
  414. ldcon/web_assets/cards/identify.current_response.html +1 -0
  415. ldcon/web_assets/cards/laser.emission.html +8 -0
  416. ldcon/web_assets/cards/measure.batch.html +1 -0
  417. ldcon/web_assets/cards/measure.change.html +1 -0
  418. ldcon/web_assets/cards/measure.target_response.html +1 -0
  419. ldcon/web_assets/cards/monitor.wavelength.html +1 -0
  420. ldcon/web_assets/cards/notify.teams.html +1 -0
  421. ldcon/web_assets/cards/optimize.settling_criteria.html +1 -0
  422. ldcon/web_assets/cards/optimize.target_feedforward.html +1 -0
  423. ldcon/web_assets/cards/optimize.target_step.html +1 -0
  424. ldcon/web_assets/cards/optimize.target_thermal.html +1 -0
  425. ldcon/web_assets/cards/plan.ecdl_recovery.html +14 -0
  426. ldcon/web_assets/cards/prepare.ecdl_recovery.html +18 -0
  427. ldcon/web_assets/cards/recover.ecdl_mode_hop.html +12 -0
  428. ldcon/web_assets/cards/report.grafana_dashboard.html +22 -0
  429. ldcon/web_assets/cards/servo.current_pid.html +1 -0
  430. ldcon/web_assets/cards/servo.dlcpro_frequency.html +1 -0
  431. ldcon/web_assets/cards/servo.ecdl_steady_lock.html +11 -0
  432. ldcon/web_assets/cards/servo.native_current.html +1 -0
  433. ldcon/web_assets/cards/study.ecdl_current_response.html +1 -0
  434. ldcon/web_assets/cards/study.ecdl_locked_current_map.html +1 -0
  435. ldcon/web_assets/cards/study.ecdl_mode_map.html +6 -0
  436. ldcon/web_assets/cards/study.ecdl_thermal_context.html +5 -0
  437. ldcon/web_assets/cards/study.target_response.html +1 -0
  438. ldcon/web_assets/cards/sweep.current_modulation.html +1 -0
  439. ldcon/web_assets/cards/sweep.dlcpro_frequency.html +1 -0
  440. ldcon/web_assets/cards/sweep.servo_modulation.html +1 -0
  441. ldcon/web_assets/cards/test.ecdl_recovery.html +4 -0
  442. ldcon/web_assets/cards/tune.ecdl_initial_point.html +1 -0
  443. ldcon/web_assets/cards/tune.ecdl_operating_point.html +13 -0
  444. ldcon/web_assets/cards/tune.ecdl_window_center.html +7 -0
  445. ldcon/web_assets/cards/tune.native_current.html +1 -0
  446. ldcon/web_assets/cards/tune.pid_coordinate_search.html +1 -0
  447. ldcon/web_assets/cards/tune.pid_factor_reduction.html +1 -0
  448. ldcon/web_assets/cards/tune.pid_online.html +1 -0
  449. ldcon/web_assets/cards/tune.temperature_once.html +18 -0
  450. ldcon/web_assets/committing-input.js +35 -0
  451. ldcon/web_assets/configuration-editor.js +194 -0
  452. ldcon/web_assets/dashboard-state.js +39 -0
  453. ldcon/web_assets/failures.html +1 -0
  454. ldcon/web_assets/failures.js +19 -0
  455. ldcon/web_assets/fallback.css +3 -0
  456. ldcon/web_assets/fallback.html +1 -0
  457. ldcon/web_assets/fallback.js +21 -0
  458. ldcon/web_assets/fleet-settings.js +20 -0
  459. ldcon/web_assets/fleet.css +9 -0
  460. ldcon/web_assets/fleet.html +8 -0
  461. ldcon/web_assets/fleet.js +130 -0
  462. ldcon/web_assets/gateway-presentation.js +27 -0
  463. ldcon/web_assets/grafana-help.js +31 -0
  464. ldcon/web_assets/help.html +22 -0
  465. ldcon/web_assets/i18n.js +279 -0
  466. ldcon/web_assets/index.html +14 -0
  467. ldcon/web_assets/laser-workspace.js +279 -0
  468. ldcon/web_assets/modulation.html +1 -0
  469. ldcon/web_assets/modulation.js +14 -0
  470. ldcon/web_assets/reservations.html +16 -0
  471. ldcon/web_assets/reservations.js +77 -0
  472. ldcon/web_assets/teams-settings.js +91 -0
  473. ldcon/web_assets/window-actions.js +37 -0
  474. ldcon/web_assets/workspace.css +99 -0
  475. ldcon/web_fleet.py +4 -0
  476. ldcon/web_server.py +4 -0
  477. ldcon-0.2.0.dist-info/METADATA +85 -0
  478. ldcon-0.2.0.dist-info/RECORD +482 -0
  479. ldcon-0.2.0.dist-info/WHEEL +5 -0
  480. ldcon-0.2.0.dist-info/entry_points.txt +5 -0
  481. ldcon-0.2.0.dist-info/licenses/LICENSE +21 -0
  482. ldcon-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,84 @@
1
+ """Non-destructive State/Activity split for provisioned detail and fleet views."""
2
+ import copy
3
+ import re
4
+ from ldcon.web.dashboard_activity import LANES, mappings, query
5
+ from ldcon.web.dashboard_state import state_mappings
6
+ from ldcon.web.metric_query_profiles import SELECTOR
7
+
8
+ DESCRIPTION=('Activity is independent of physical State. Monitor uses its own published heartbeat; '
9
+ 'NEEDS INSPECTION means automatic follow-up is paused. Agent task presence does not prove a physical move. '
10
+ 'Target acceptance is distinct from application. Sweep stages come from worker publications. '
11
+ 'Simultaneous lanes are shown independently. Display sampling is 5 s with 25 s expiry; '
12
+ 'shorter operations can be absent from the timeline. Last target acceptance retains its recorded timestamp. '
13
+ 'No owner command or lifecycle API is used by this display.')
14
+
15
+
16
+ def state_only(expr):
17
+ # Preserve all existing optical/dither precedence and configured references.
18
+ expr=re.sub(r'^ operation = .*\n','',expr,flags=re.M)
19
+ return expr.replace(' or on(laser) (operation >= 14 <= 21)','')
20
+
21
+
22
+ def panels(ds, selector, *, fleet, next_id, y, h):
23
+ columns={chr(65+i):label for i,label in enumerate(LANES.values())}
24
+ targets=[dict(refId=ref,expr=query(lane,selector),datasource=ds,instant=True,range=False,format='table')
25
+ for ref,lane in zip(columns,LANES)]
26
+ scope=selector+',producer="dashboard-metadata",kind="dashboard_metadata"'
27
+ last='max by(laser) (last_over_time(ldcon_target_last_accepted_unix_s{'+scope+'}[25s]) > 0) * 1000'
28
+ targets.append(dict(refId='F',expr=last,datasource=ds,instant=True,range=False,format='table'))
29
+ columns['F']='Last target accepted'
30
+ table=dict(id=next_id,title='Activity — current',type='table',datasource=ds,gridPos=dict(x=0,y=y,w=24,h=5),
31
+ description=DESCRIPTION,targets=targets,
32
+ transformations=[dict(id='joinByField',options=dict(byField='laser',mode='outerTabular')),
33
+ dict(id='organize',options=dict(includeByName={'laser':True,**{'Value #'+r:True for r in columns}},
34
+ indexByName={'laser':0,**{'Value #'+r:i+1 for i,r in enumerate(columns)}},
35
+ renameByName={'laser':'Laser',**{'Value #'+r:label for r,label in columns.items()}}))],
36
+ fieldConfig=dict(defaults=dict(unit='none',noValue='—',mappings=[]),overrides=[
37
+ *[dict(matcher=dict(id='byName',options=label),properties=[dict(id='mappings',value=mappings()),
38
+ dict(id='custom.cellOptions',value=dict(type='color-text'))]) for label in LANES.values()],
39
+ dict(matcher=dict(id='byName',options='Last target accepted'),properties=[dict(id='unit',value='dateTimeAsLocal')])]),
40
+ options=dict(showHeader=True,sortBy=[dict(displayName='Laser',desc=False)]))
41
+ timeline=dict(id=next_id+1,title='Activity — timeline',type='state-timeline',datasource=ds,
42
+ gridPos=dict(x=12,y=y+5,w=12,h=h),description=DESCRIPTION,
43
+ targets=[dict(refId=chr(65+i),expr=query(lane,selector),legendFormat='{{laser}} · '+label,
44
+ datasource=ds,instant=False,range=True) for i,(lane,label) in enumerate(LANES.items())],
45
+ fieldConfig=dict(defaults=dict(unit='none',noValue='UNKNOWN',mappings=mappings(),
46
+ color=dict(mode='palette-classic'),custom=dict(spanNulls=False,lineWidth=1)),overrides=[]),
47
+ options=dict(mergeValues=True,showValue='auto',alignValue='left',rowHeight=.8,
48
+ legend=dict(showLegend=False),tooltip=dict(mode='single')))
49
+ return table,timeline
50
+
51
+
52
+ def apply(document):
53
+ d=copy.deepcopy(document)
54
+ if any(p.get('title')=='Activity — current' for p in d.get('panels',[])):
55
+ for panel in d['panels']:
56
+ if panel.get('type')!='state-timeline':continue
57
+ if panel.get('title') in ('State','All lasers — state timeline'):
58
+ panel['gridPos'].update(x=0,w=12)
59
+ elif panel.get('title')=='Activity — timeline':
60
+ panel['gridPos'].update(x=12,w=12)
61
+ return d
62
+ fleet=d.get('uid')=='ldcon-fleet';selector='laser!="",simulation="false"' if fleet else SELECTOR
63
+ states=[p for p in d['panels'] if p['title'] in ('State and activity','Lock status','State','All lasers — current state','All lasers — state timeline')]
64
+ timeline=next((p for p in states if p['type']=='state-timeline'),None)
65
+ if not timeline:raise ValueError('No unique physical state timeline to split')
66
+ for panel in states:
67
+ for t in panel.get('targets',[]):
68
+ if 'operation_state_code' in t.get('expr',''):t['expr']=state_only(t['expr'])
69
+ if panel['title'] in ('State and activity','Lock status'):panel['title']='State'
70
+ panel['description']='Physical lock, optical quality and owner state only. See Activity for monitoring, adjustment, target changes and sweeps. Freshness and fault precedence are unchanged.'
71
+ if panel['type']!='table':panel['fieldConfig']['defaults']['mappings']=state_mappings()
72
+ else:
73
+ for o in panel['fieldConfig'].get('overrides',[]):
74
+ if o.get('matcher',{}).get('options')=='State':
75
+ for prop in o.get('properties',[]):
76
+ if prop['id']=='mappings':prop['value']=state_mappings()
77
+ anchor=timeline['gridPos']['y'];old_h=timeline['gridPos']['h'];height=max(8,old_h)
78
+ for p in d['panels']:
79
+ if p['gridPos']['y']>=anchor+old_h:p['gridPos']['y']+=height-old_h
80
+ if p['gridPos']['y']>=anchor:p['gridPos']['y']+=5
81
+ timeline['gridPos'].update(x=0,w=12,h=height)
82
+ added=panels(timeline['datasource'],selector,fleet=fleet,next_id=max(p['id'] for p in d['panels'])+1,y=anchor,h=height)
83
+ d['panels'].extend(added);d['panels'].sort(key=lambda p:(p['gridPos']['y'],p['gridPos']['x']))
84
+ return d
@@ -0,0 +1,492 @@
1
+ """Offline Grafana export. All device coordinates come from the laser snapshot."""
2
+ import copy
3
+ import json
4
+ import math
5
+ import re
6
+ from pathlib import Path
7
+
8
+ C_NM=299792458e9
9
+ DEFAULTS=dict(reference_wavelength_nm=None,reference_frequency_hz=None,reference_card_id=None,
10
+ datasource_uid='',title='',offset_range_mhz=1000000.,metric_overrides={},
11
+ sample_interval_s=.5,max_sample_age_s=1.,report_card_ids=None,
12
+ report_quantity_ids=None,display_tolerance_hz=None)
13
+ STATE_CODES={'stopped':0,'recovering':1,'acquiring':2,'locked':3,'hopped':4,
14
+ 'failed':5,'mode_degraded':6,'waiting_for_measurement':7,'tracking':8,
15
+ 'confirming_mode_loss':4,'mapping':8,'monitoring':8,'legacy_flag_false_pi_unknown':9,'native_pi_on_quality_unknown':10,'native_pi_held':11}
16
+ # DLC hold means mode=idle/PI disabled, not emission off. A programmed scan is
17
+ # physical tracking; its sweep lifecycle is shown separately in Activity.
18
+ STATE_CODES.update(hold=11, sweeping=8)
19
+
20
+
21
+ def parameters(card,overrides):
22
+ if not isinstance(overrides,dict) or not isinstance(card['parameters'],dict):raise ValueError('Expected parameter mappings')
23
+ if (set(card['parameters'])|set(overrides))-DEFAULTS.keys():raise ValueError('Unknown Grafana parameter')
24
+ p=copy.deepcopy({**DEFAULTS,**card['parameters'],**overrides})
25
+ for key in ('reference_wavelength_nm','reference_frequency_hz','offset_range_mhz','sample_interval_s','max_sample_age_s','display_tolerance_hz'):
26
+ value=p[key]
27
+ if value is None and (key.startswith('reference_') or key=='display_tolerance_hz'):continue
28
+ if type(value) not in (int,float) or not math.isfinite(value) or value<=0:raise ValueError('Invalid '+key)
29
+ if p['reference_wavelength_nm'] is not None and p['reference_frequency_hz'] is not None:raise ValueError('Specify reference wavelength or frequency, not both')
30
+ for key in ('datasource_uid','title'):
31
+ if not isinstance(p[key],str) or len(p[key])>200:raise ValueError('Invalid '+key)
32
+ if p['reference_card_id'] is not None and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,79}',str(p['reference_card_id'])):raise ValueError('Invalid reference card')
33
+ from ldcon.core.card_reporting import REPORT_QUANTITIES
34
+ from ldcon.core.metric_quantities import QUANTITIES
35
+ for key in ('report_card_ids','report_quantity_ids'):
36
+ value=p[key]
37
+ if value is not None and (not isinstance(value,list) or any(not isinstance(v,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,119}',v) for v in value) or len(value)!=len(set(value))):raise ValueError('Invalid '+key)
38
+ if not isinstance(p['metric_overrides'],dict) or any(k not in set(METRICS)|set(REPORT_QUANTITIES)|set(QUANTITIES) or not isinstance(v,str) or not v or len(v)>4096 for k,v in p['metric_overrides'].items()):raise ValueError('Invalid metric overrides')
39
+ return p
40
+
41
+
42
+ def resolve_reference(p,deck,invocations,active=None):
43
+ p=copy.deepcopy(p)
44
+ if p['reference_wavelength_nm'] is not None:return p,'explicit wavelength'
45
+ if p['reference_frequency_hz'] is not None:
46
+ p['reference_wavelength_nm']=C_NM/p.pop('reference_frequency_hz');p['reference_frequency_hz']=None
47
+ return p,'explicit frequency'
48
+ requested=p['reference_card_id']
49
+ candidates=[]
50
+ if active:
51
+ live=active.get('runtime_parameters',{})
52
+ for c in active.get('cards',[]):
53
+ if requested and c['id']!=requested:continue
54
+ values=c.get('params',{});source='running card '+c['id']
55
+ if (live.get('status')=='running' and live.get('job_id')==active.get('id')
56
+ and live.get('card_id')==c['id'] and live.get('revision')):
57
+ values={**values,**live.get('parameters',{})}
58
+ source+=' at runtime revision '+str(live['revision'])
59
+ candidates.append((c['id'],values,source))
60
+ cards=[c for c in deck['cards'] if c['operation']!='report.grafana_dashboard' and (not requested or c['id']==requested)]
61
+ cards.sort(key=lambda c:0 if c['operation'].startswith('servo.') else 1)
62
+ candidates.extend((c['id'],{**c['parameters'],**invocations.get(c['id'],{})},'assigned card '+c['id']) for c in cards)
63
+ for _,values,source in candidates:
64
+ frequency=values.get('target_frequency_hz');wavelength=values.get('target_wavelength_nm')
65
+ if type(frequency) in (int,float) and math.isfinite(frequency) and frequency>0:p['reference_wavelength_nm']=C_NM/frequency;_reference_tolerance(p,values);return p,source
66
+ if type(wavelength) in (int,float) and math.isfinite(wavelength) and wavelength>0:p['reference_wavelength_nm']=wavelength;_reference_tolerance(p,values);return p,source
67
+ raise ValueError('Set a reference wavelength/frequency or select a card with a target')
68
+
69
+
70
+ def _reference_tolerance(p,values):
71
+ tolerance=values.get('tolerance_hz')
72
+ if p['display_tolerance_hz'] is None and type(tolerance) in (int,float) and math.isfinite(tolerance) and tolerance>0:
73
+ p['display_tolerance_hz']=tolerance
74
+
75
+
76
+ from ldcon.web.metric_query_profiles import METRICS,report_queries
77
+ from ldcon.core.card_reporting import collect_reports
78
+ from ldcon.web.dashboard_state import state_mappings
79
+
80
+ # Identical priorities for every laser. Hardware-specific quantities are omitted
81
+ # when unavailable; they never change the ordering of common panels.
82
+ DISPLAY_ORDER = (
83
+ 'controller.state', 'optical.frequency', 'electrical.current',
84
+ 'model.current_estimate', 'control.current_setpoint', 'control.current_bias_setpoint',
85
+ 'control.current_total_setpoint', 'control.current_dac',
86
+ 'control.current_axis', 'control.temperature_axis', 'thermal.temperature',
87
+ 'control.temperature_setpoint', 'control.piezo_voltage', 'control.piezo_setpoint', 'control.piezo_dac',
88
+ 'optical.single_mode', 'optical.secondary_peaks', 'optical.main_peak_width',
89
+ 'response.current_dither', 'response.dither_quality', 'optical.mode_hops',
90
+ 'controller.actions',
91
+ )
92
+
93
+
94
+
95
+ def source_context(laser):
96
+ context=dict(wsu_channel='',wsu_cycle='',pzt_host='',current_unit='',temperature_unit='',dither_vpp=None)
97
+ for device in laser['devices'].values():
98
+ c=device['connection'];source=c.get('wavemeter',c.get('source',c))
99
+ if 'channel' in source and ('cycle' in source):
100
+ context.update(wsu_channel=str(source['channel']),wsu_cycle=str(source['cycle']))
101
+ pzt=c.get('resources',{}).get('piezo',{})
102
+ if pzt.get('host'):context['pzt_host']=pzt['host']
103
+ settings=device.get('control_settings',{});axes=settings.get('axes',{})
104
+ for axis,key in [('current','current_unit'),('temperature','temperature_unit')]:
105
+ if axes.get(axis,{}).get('unit'):context[key]=axes[axis]['unit']
106
+ dither=settings.get('steady_lock',{}).get('dither',{})
107
+ if type(dither.get('effective_amplitude_peak')) in (int,float):context['dither_vpp']=2*dither['effective_amplitude_peak']
108
+ return context
109
+
110
+
111
+ def textbox(name,label,value):
112
+ text=str(value)
113
+ return dict(name=name,label=label,type='textbox',query=text,current=dict(text=text,value=text),options=[])
114
+
115
+
116
+ def _report_selection(laser, p, deck, card_parameters=None):
117
+ if deck is None:
118
+ raise ValueError('A deck snapshot is required to collect card reporting quantities')
119
+ selection = collect_reports(deck, laser, p.get('report_card_ids'), p.get('report_quantity_ids'), card_parameters=card_parameters)
120
+ context = source_context(laser)
121
+ bindings = {}; unavailable = list(selection['unavailable_quantities'])
122
+ for quantity in selection['selected_quantities']:
123
+ if quantity['id'] == 'optical.wavelength':
124
+ continue
125
+ queries, reason = report_queries(quantity, laser, context, p['metric_overrides'],
126
+ tolerance='${lock_tolerance_hz}' if p.get('display_tolerance_hz') else None)
127
+ if reason:
128
+ unavailable.append(dict(id=quantity['id'], reason=reason))
129
+ else:
130
+ bindings[quantity['id']] = queries
131
+ if p.get('report_quantity_ids') is not None:
132
+ rejected = set(p['report_quantity_ids']) - bindings.keys() - {'optical.wavelength'}
133
+ if rejected:
134
+ raise ValueError('No report source binding for selected quantities: '+', '.join(sorted(rejected)))
135
+ selection['unavailable_quantities'] = unavailable
136
+ selection['rendered_quantity_ids'] = list(bindings)
137
+ return selection, bindings, context
138
+
139
+
140
+ def build_dashboard(laser,p,*,deck=None,card_parameters=None):
141
+ if p['reference_wavelength_nm'] is None:
142
+ raise ValueError('Resolve reference at execution time before export')
143
+ selection, bindings, context = _report_selection(laser, p, deck, card_parameters)
144
+ ds = dict(type='prometheus', uid='${DS_VM}')
145
+ variables = [dict(name='DS_VM', label='Data source', type='datasource', query='prometheus', refresh=1,
146
+ current=dict(text=p['datasource_uid'], value=p['datasource_uid']), options=[]),
147
+ textbox('laser', 'Laser', laser['name']),
148
+ textbox('line_nm', 'Internal reference', format(p['reference_wavelength_nm'], '.17g')),
149
+ textbox('offset_range_mhz', 'Offset range (MHz)', p['offset_range_mhz'])]
150
+ if p.get('display_tolerance_hz'):
151
+ variables.append(textbox('lock_tolerance_hz', 'Configured lock tolerance (Hz)', p['display_tolerance_hz']))
152
+ # Retain the exact internal conversion reference without a wavelength UI.
153
+ next(v for v in variables if v['name'] == 'line_nm')['hide'] = 2
154
+ for name in ('wsu_channel', 'wsu_cycle', 'pzt_host'):
155
+ if context[name]:
156
+ variables.append(textbox(name, name.replace('_', ' ').capitalize(), context[name]))
157
+ stats = []; timelines = []; series = []
158
+
159
+ def panel(quantity, queries, *, title=None, kind='timeseries', unit=None):
160
+ title = title or quantity['title']
161
+ unit = unit or quantity['unit']
162
+ targets = []
163
+ for i, (label, expr) in enumerate(queries):
164
+ targets.append(dict(refId=chr(65+i), expr=expr, legendFormat=label, datasource=ds,
165
+ instant=kind=='stat', range=kind!='stat'))
166
+ defaults = dict(unit='none', noValue='Unknown', mappings=[], color=dict(mode='palette-classic'),
167
+ thresholds=dict(mode='absolute', steps=[dict(color='green', value=None)]))
168
+ if kind=='timeseries':
169
+ defaults['custom'] = dict(lineWidth=1, spanNulls=False, showPoints='never', axisLabel=title, drawStyle='line')
170
+ options = dict(legend=dict(displayMode='list', placement='bottom'), tooltip=dict(mode='multi'))
171
+ if kind=='stat':
172
+ options = dict(reduceOptions=dict(calcs=['lastNotNull'], fields='', values=False), colorMode='none', graphMode='none', textMode='value')
173
+ if quantity['id']=='controller.state':
174
+ defaults['mappings'] = state_mappings()
175
+ if kind=='stat': options['colorMode']='background'
176
+ if kind=='state-timeline':
177
+ options.update(showValue='always', alignValue='left', mergeValues=True, rowHeight=.8)
178
+ defaults['custom'] = dict(spanNulls=False, lineWidth=1)
179
+ value = dict(title=title, type=kind, datasource=ds, targets=targets,
180
+ description=quantity['meaning']+' '+quantity['constraints']+' Reporting cards: '+', '.join(quantity['card_ids'])+'.',
181
+ fieldConfig=dict(defaults=defaults, overrides=[]), options=options)
182
+ (stats if kind=='stat' else timelines if kind=='state-timeline' else series).append(value)
183
+ return value
184
+
185
+ for quantity in sorted(selection['selected_quantities'], key=lambda q: DISPLAY_ORDER.index(q['id']) if q['id'] in DISPLAY_ORDER else len(DISPLAY_ORDER)):
186
+ identifier = quantity['id']
187
+ if identifier not in bindings:
188
+ continue
189
+ queries = bindings[identifier]
190
+ if identifier=='controller.state':
191
+ panel(quantity, queries, kind='stat', title='Lock status')
192
+ panel(quantity, queries, kind='state-timeline', title='Lock status')
193
+ continue
194
+ if identifier=='optical.frequency':
195
+ from ldcon.web.metric_query_profiles import fleet_measurement_queries
196
+ expr = fleet_measurement_queries('laser=~"${laser:regex}"')['offset']
197
+ panel(quantity, [('Offset', expr)], kind='stat', title='Latest frequency offset (MHz)', unit='MHz')
198
+ value = panel(quantity, [('Offset (MHz)', expr)], title='Frequency offset from target (MHz)', unit='MHz')
199
+ value.update(interval='10ms', maxDataPoints=8192)
200
+ value['options']['legend'].update(showLegend=True,displayMode='list',calcs=['mean','stdDev'])
201
+ value['description'] += ' Uses the fresh saved laser target at each timestamp. Missing target metadata leaves a gap; no fixed reference or lock-state filter.'
202
+ continue
203
+ if identifier=='optical.mode_hops':
204
+ panel(quantity, [('Confirmed hops', 'sum(sum_over_time('+queries[0][1]+'[$__range]))')], kind='stat', title='Hops in range')
205
+ panel(quantity, [('Confirmed hops / min', 'sum by(laser) (sum_over_time('+queries[0][1]+'[1m]))')], unit='events/min')
206
+ continue
207
+ display_unit = quantity['unit']
208
+ if display_unit == 'A':
209
+ queries = [(label, '('+expr+') * 1000') for label, expr in queries]
210
+ display_unit = 'mA'
211
+ if identifier=='response.current_dither' and display_unit=='nm/V':
212
+ queries = [(label, '('+expr+') * 1000') for label, expr in queries]
213
+ display_unit = 'fm/mV'
214
+ value = panel(quantity, queries, title=quantity['title']+' ('+display_unit+')' if display_unit not in ('1','mixed') else quantity['title'], unit=display_unit)
215
+ if identifier=='response.current_dither':
216
+ value['fieldConfig']['overrides'] = [dict(matcher=dict(id='byName', options=label), properties=[dict(id='color', value=dict(mode='fixed', fixedColor=color))])
217
+ for label, color in [('Response','green'), ('Warning threshold','orange'), ('Boundary threshold','red')]]
218
+ if identifier=='response.dither_quality':
219
+ value['fieldConfig']['defaults']['custom']['axisLabel'] = 'Valid fit (1=yes)'
220
+ value['fieldConfig']['overrides'] = [dict(matcher=dict(id='byName', options='Source age (s)'),
221
+ properties=[dict(id='unit', value='none'), dict(id='custom.axisPlacement', value='right'), dict(id='custom.axisLabel', value='Source age (s)')])]
222
+ if identifier == 'controller.actions':
223
+ value['fieldConfig']['defaults']['noValue'] = 'No recorded commands'
224
+ value['description'] += ' Native PI updates are not command_applied events. An absent event series is not a measured zero or a stopped PI.'
225
+ if identifier in ('electrical.current','control.current_axis','control.temperature_axis','control.piezo_voltage','control.current_dac','optical.fringe_fill'):
226
+ panel(quantity, [(label, 'last_over_time(('+expr+')[30s:1s])') for label, expr in queries], kind='stat', title=value['title'], unit=display_unit)
227
+ # Summary count follows selected quantities; detailed plots always retain
228
+ # four columns. No phantom placeholders are added for absent ECDL hardware.
229
+ for i, value in enumerate(stats):
230
+ row = i//6; count = min(6, len(stats)-row*6); column=i%6
231
+ value['gridPos'] = dict(x=column*24//count, y=row*4, w=(column+1)*24//count-column*24//count, h=4)
232
+ y = ((len(stats)+5)//6)*4
233
+ for value in timelines:
234
+ value['gridPos'] = dict(x=0,y=y,w=24,h=4);y+=4
235
+ for i, value in enumerate(series):
236
+ value['gridPos'] = dict(x=(i%4)*6,y=y+(i//4)*8,w=6,h=8)
237
+ panels = stats+timelines+series
238
+ for i, value in enumerate(panels):value['id']=i+1
239
+ dashboard = dict(id=None,uid='ldcon-'+laser['id'],title=p['title'] or 'Laser control: '+laser['name'],schemaVersion=42,version=1,
240
+ editable=True,tags=['ldcon','laser-control','laser-detail'],timezone='browser',refresh='1s',time=dict(**{'from':'now-5m','to':'now'}),
241
+ templating=dict(list=variables),panels=panels,annotations=dict(list=[]),
242
+ links=[dict(title='All lasers', type='link', url='/d/ldcon-fleet', targetBlank=False)])
243
+ from ldcon.web.grafana_quality import apply
244
+ from ldcon.web.grafana_activity import apply as activity
245
+ dashboard = activity(apply(dashboard))
246
+ if any(d.get('type') == 'dlcpro.laser.owner' for d in laser.get('devices', {}).values()):
247
+ from ldcon.web.grafana_dlcpro import complete_detail
248
+ dashboard = complete_detail(dashboard)
249
+ return dashboard
250
+
251
+
252
+ def build_fleet_dashboard(datasource_uid=''):
253
+ """Offline, automatically discovered fleet overview; no report card needed."""
254
+ from ldcon.web.metric_query_profiles import fleet_status_query, fleet_measurement_queries
255
+ ds = dict(type='prometheus', uid='${DS_VM}')
256
+ query = fleet_status_query()
257
+ defaults = dict(unit='none', noValue='UNKNOWN', mappings=state_mappings(),
258
+ color=dict(mode='thresholds'), thresholds=dict(mode='absolute', steps=[dict(color='gray', value=None)]))
259
+ quantities=fleet_measurement_queries()
260
+ columns={'A':'State','B':'Latest wavelength (nm)','C':'Latest offset (MHz)',
261
+ 'D':'Latest sample age (s)','E':'Mean wavelength (5 min, nm)',
262
+ 'F':'Mean offset (5 min, MHz)','G':'RMS offset (5 min, MHz)','H':'Configured target (nm)'}
263
+ table = dict(id=1, title='All lasers — current state', type='table', datasource=ds,
264
+ gridPos=dict(x=0, y=0, w=24, h=6),
265
+ targets=[dict(refId=ref,expr=expr,datasource=ds,instant=True,range=False,format='table')
266
+ for ref,expr in zip(columns,[query,quantities['wavelength'],quantities['offset'],quantities['sample_age'],
267
+ quantities['wavelength_mean'],quantities['offset_mean'],quantities['rms'],quantities['reference']])],
268
+ transformations=[dict(id='joinByField',options=dict(byField='laser',mode='outerTabular')),
269
+ dict(id='organize',options=dict(
270
+ includeByName={'laser':True,**{'Value #'+k:True for k in columns}},
271
+ indexByName={'laser':0,**{'Value #'+k:i+1 for i,k in enumerate(columns)}},
272
+ renameByName={'laser':'Laser',**{'Value #'+k:v for k,v in columns.items()}}))],
273
+ fieldConfig=dict(defaults=dict(unit='none',noValue='—',mappings=[],color=dict(mode='fixed',fixedColor='text')),
274
+ overrides=[dict(matcher=dict(id='byName',options='State'),properties=[
275
+ dict(id='mappings',value=state_mappings()),
276
+ dict(id='custom.cellOptions',value=dict(type='color-text'))]),
277
+ *[dict(matcher=dict(id='byName',options=columns[k]),properties=[dict(id='decimals',value=9)]) for k in ('B','E','H')],
278
+ *[dict(matcher=dict(id='byName',options=columns[k]),properties=[dict(id='decimals',value=3)]) for k in ('C','D','F','G')]]),
279
+ options=dict(showHeader=True,sortBy=[dict(displayName='Laser',desc=False)]))
280
+ timeline = dict(id=2, title='All lasers — state timeline', type='state-timeline', datasource=ds,
281
+ gridPos=dict(x=0, y=6, w=24, h=8),
282
+ targets=[dict(refId='A', expr=query, legendFormat='{{laser}}', datasource=ds, instant=False, range=True)],
283
+ fieldConfig=dict(defaults={**copy.deepcopy(defaults), 'color':dict(mode='palette-classic'), 'custom': dict(spanNulls=False, lineWidth=1)}, overrides=[]),
284
+ options=dict(mergeValues=True, showValue='auto', alignValue='left', rowHeight=.8,
285
+ legend=dict(displayMode='list', placement='bottom'), tooltip=dict(mode='single')))
286
+ description = ('Lasers are discovered from real LDCon telemetry, retained as UNKNOWN for 30 days after their last record. '
287
+ 'Only an explicit fresh output-disabled readback means OFF. HELD means PI is disabled, not emission off. '
288
+ 'Active sweep, target, operating-point and study cards are shown as operations; fault, read failure and output-off take precedence. Monitor/agent events do not replace servo state. Missing or ambiguous physical evidence is UNKNOWN. '
289
+ 'Display expiry is 25 seconds; this is not a control tolerance. Per-laser detail dashboards remain separate.')
290
+ for panel in (table, timeline):
291
+ panel['description'] = description
292
+ table['description'] += (' Measurements do not depend on LOCKED: use raw wavemeter samples from a unique real cycle '
293
+ '(exclude aggregate cycle -1). Latest is the last sample within 25 seconds; Mean is the raw-sample arithmetic mean '
294
+ 'over 5 minutes. Offset = measured frequency minus the current saved laser target, read independently every 5 seconds. '
295
+ 'Target metadata expires after 25 seconds. RMS = sqrt(frequency variance + mean offset squared), relative to '
296
+ 'the current saved target (not historical targets within the averaging window) '
297
+ 'over 5 minutes, including bias. No resampling or zero-filled gaps. Missing, stale or ambiguous measurements remain '
298
+ 'blank; missing reference leaves only offset/RMS blank. All numeric units are in column labels.')
299
+ document = dict(id=None, uid='ldcon-fleet', title='LDCon — all lasers', schemaVersion=42, version=1,
300
+ editable=True, tags=['ldcon', 'laser-control', 'fleet'], timezone='browser', refresh='5s',
301
+ time={'from': 'now-1h', 'to': 'now'}, panels=[table, timeline], annotations=dict(list=[]),
302
+ templating=dict(list=[dict(name='DS_VM', label='Data source', type='datasource', query='prometheus',
303
+ refresh=1, current=dict(text=datasource_uid, value=datasource_uid), options=[])]),
304
+ links=[dict(title='Laser detail dashboards', type='dashboards', tags=['laser-detail'],
305
+ asDropdown=True, includeVars=False, keepTime=True, targetBlank=False)])
306
+ from ldcon.web.grafana_activity import apply
307
+ return apply(document)
308
+
309
+
310
+ def normalize_dashboard(document):
311
+ """Apply presentation fixes to an actual exported detail dashboard.
312
+
313
+ Preserve UID, panel IDs, source/reference values, annotations, visual
314
+ thresholds and device-specific panels. Unknown panel quantities fail closed
315
+ instead of guessing a physical unit. No import/publication is performed.
316
+ """
317
+ from ldcon.web.metric_query_profiles import SELECTOR, fleet_status_query, owner_metric
318
+ dashboard = copy.deepcopy(document)
319
+ aliases = {
320
+ 'Lock status': ('controller.state', 'Lock status', None),
321
+ 'Frequency offset': ('optical.frequency', 'Frequency offset', 'MHz'),
322
+ 'Frequency offset from reference': ('optical.frequency', 'Frequency offset from reference', 'MHz'),
323
+ 'Measured diode current': ('electrical.current', 'Measured diode current', 'mA'),
324
+ 'Estimated modulated current': ('model.current_estimate', 'Estimated modulated current', 'mA'),
325
+ 'Current setpoint': ('control.current_setpoint', 'Current setpoint', 'mA'),
326
+ 'Current modulation DAC': ('control.current_dac', 'Current modulation DAC', 'count'),
327
+ 'Laser current input': ('control.current_axis', 'Current-controller input', 'V'),
328
+ 'Current-controller input': ('control.current_axis', 'Current-controller input', 'V'),
329
+ 'TEC input': ('control.temperature_axis', 'TEC-controller input', 'V'),
330
+ 'TEC-controller input': ('control.temperature_axis', 'TEC-controller input', 'V'),
331
+ 'Measured temperature': ('thermal.temperature', 'Measured temperature', 'K'),
332
+ 'Temperature setpoint': ('control.temperature_setpoint', 'Temperature setpoint', 'degC'),
333
+ 'Piezo input': ('control.piezo_voltage', 'Measured PZT voltage', 'V'),
334
+ 'Measured PZT voltage': ('control.piezo_voltage', 'Measured PZT voltage', 'V'),
335
+ 'Lock DAC': ('control.piezo_dac', 'PZT control DAC', 'count'),
336
+ 'PZT control DAC': ('control.piezo_dac', 'PZT control DAC', 'count'),
337
+ 'Fringe fill factor F2': ('optical.main_peak_width', 'Fringe fill factor F2 — recorded proxy', None),
338
+ 'Single-mode measure': ('optical.main_peak_width', 'Fringe fill factor F2 — recorded proxy', None),
339
+ 'Qualified single-mode classification': ('optical.single_mode', 'Qualified single-mode classification', None),
340
+ 'Main fringe peak width': ('optical.main_peak_width', 'Main fringe peak width', None),
341
+ 'Position-free secondary peaks': ('optical.secondary_peaks', 'Position-free secondary peaks', None),
342
+ 'Dither response': ('response.current_dither', 'Current dither response', 'fm/mV'),
343
+ 'Current dither response': ('response.current_dither', 'Current dither response', 'fm/mV'),
344
+ 'Dither analysis freshness': ('response.dither_quality', 'Dither analysis freshness', None),
345
+ 'Hops in range': ('optical.mode_hops', 'Hops in range', None),
346
+ 'Confirmed mode hops': ('optical.mode_hops', 'Confirmed mode hops', 'events/min'),
347
+ 'Controller actions': ('controller.actions', 'Applied controller commands', 'events/min'),
348
+ 'Applied controller commands': ('controller.actions', 'Applied controller commands', 'events/min'),
349
+ }
350
+ groups = {'stat': [], 'state-timeline': [], 'timeseries': []}
351
+ changes = []
352
+ coordinate_units = {}
353
+ for source_panel in dashboard.get('panels', []):
354
+ matched = re.fullmatch(r'(.+) \((V|A|mA|K|degC|count)\)', source_panel['title'])
355
+ if matched:
356
+ coordinate_units.setdefault(matched[1], set()).add(matched[2])
357
+ for panel in dashboard.get('panels', []):
358
+ old_title = panel['title']
359
+ if 'wavelength' in old_title.lower():
360
+ changes.append(dict(panel_id=panel['id'], action='remove wavelength display'))
361
+ continue
362
+ if old_title in ('Fringe fill factor F2', 'Single-mode measure (fringe fill factor)'):
363
+ # Replace obsolete F2 displays with the current classification and
364
+ # qualified peak-width measurement. No F2 thresholds are reused.
365
+ classification = panel['type'] == 'stat'
366
+ panel['title'] = ('Qualified single-mode classification' if classification else 'Main fringe peak width')
367
+ field = 'single_mode' if classification else 'fringe_metrics.main_fwhm_fraction'
368
+ first = panel['targets'][0]
369
+ panel['targets'] = [{**first, 'expr': owner_metric(field), 'legendFormat': panel['title'],
370
+ 'instant': classification, 'range': not classification}]
371
+ panel['fieldConfig'] = dict(defaults=dict(unit='none', mappings=[], color=dict(mode='palette-classic'),
372
+ thresholds=dict(mode='absolute', steps=[dict(color='gray', value=None)])), overrides=[])
373
+ if classification:
374
+ panel['fieldConfig']['defaults']['mappings'] = [dict(type='value', options={
375
+ '0': dict(text='DEGRADED', color='orange'), '1': dict(text='QUALIFIED', color='green')})]
376
+ panel['description'] = ('Classification under the configured optical calibration; no F2 thresholds or universal purity claims.'
377
+ if classification else 'Main peak FWHM normalized by the qualified fringe period; not laser linewidth. No F2 thresholds are used.')
378
+ changes.append(dict(panel_id=panel['id'], old_title=old_title, action='replace obsolete F2 display with current qualified optical quantity'))
379
+ old_title = panel['title']
380
+ if panel['type'] not in groups:
381
+ raise ValueError('Review unsupported panel before normalization: ' + old_title)
382
+ title = re.sub(r'\s*\([^)]*\)\s*$', '', old_title)
383
+ if title not in aliases:
384
+ raise ValueError('Review unknown quantity before normalization: ' + old_title)
385
+ quantity, title, unit = aliases[title]
386
+ if quantity in ('control.current_axis', 'control.temperature_axis'):
387
+ source_name = re.sub(r'\s*\([^)]*\)\s*$', '', old_title)
388
+ proven = coordinate_units.get(source_name, set())
389
+ if len(proven) != 1:
390
+ raise ValueError('Review missing/ambiguous actuator coordinate unit: ' + old_title)
391
+ unit = next(iter(proven))
392
+ defaults = panel.setdefault('fieldConfig', {}).setdefault('defaults', {})
393
+ prior_unit = defaults.get('unit')
394
+ if unit == 'mA' and (prior_unit == 'amp' or old_title.endswith('(A)')):
395
+ for target in panel.get('targets', []):
396
+ target['expr'] = '(' + target['expr'] + ') * 1000'
397
+ for step in defaults.get('thresholds', {}).get('steps', []):
398
+ if step.get('value') is not None:
399
+ step['value'] *= 1000
400
+ for field in ('min', 'max'):
401
+ if field in defaults:
402
+ defaults[field] *= 1000
403
+ changes.append(dict(panel_id=panel['id'], action='scale amperes to milliamperes; physical values unchanged'))
404
+ elif unit == 'mA' and prior_unit not in ('mamp', 'none') and not old_title.endswith('(mA)'):
405
+ raise ValueError('Current scaling is ambiguous: ' + old_title)
406
+ panel['title'] = title + (' (' + unit + ')' if unit else '')
407
+ defaults['unit'] = 'none'
408
+ defaults['noValue'] = 'Unknown'
409
+ if panel['type'] == 'timeseries':
410
+ defaults.setdefault('custom', {}).update(axisLabel=panel['title'], lineWidth=1, spanNulls=False)
411
+ for override in panel['fieldConfig'].get('overrides', []):
412
+ for prop in override.get('properties', []):
413
+ if prop['id'] == 'unit':
414
+ if prop['value'] not in ('s', 'none', 'suffix:count'):
415
+ raise ValueError('Review override axis units: ' + old_title)
416
+ prop['value'] = 'none'
417
+ # Older saved queries used dotted field paths as bare metric names.
418
+ for target in panel.get('targets', []):
419
+ target['expr'] = re.sub(r'\b(ldcon_[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\{',
420
+ lambda m: '{__name__=' + json.dumps(m[1]) + ',', target.get('expr', ''))
421
+ if quantity == 'controller.state':
422
+ first = panel['targets'][0]
423
+ panel['targets'] = [{**first, 'expr': fleet_status_query(SELECTOR), 'legendFormat': 'State',
424
+ 'instant': panel['type'] == 'stat', 'range': panel['type'] != 'stat'}]
425
+ defaults['mappings'] = state_mappings()
426
+ defaults['custom'] = dict(spanNulls=False, lineWidth=1)
427
+ if panel['type'] == 'state-timeline':
428
+ defaults['color'] = dict(mode='palette-classic')
429
+ panel.setdefault('options', {}).update(mergeValues=True, showValue='auto')
430
+ if quantity == 'response.dither_quality':
431
+ defaults.setdefault('custom', {})['axisLabel'] = 'Valid fit (1=yes)'
432
+ panel['fieldConfig']['overrides'] = [dict(matcher=dict(id='byName', options='Source age (s)'),
433
+ properties=[dict(id='unit', value='none'), dict(id='custom.axisPlacement', value='right'),
434
+ dict(id='custom.axisLabel', value='Source age (s)')])]
435
+ if quantity == 'control.piezo_voltage':
436
+ from ldcon.web.metric_query_profiles import pzt_driver_query
437
+ for target in panel['targets']:
438
+ field = ('output_voltage' if 'laser_servo_driver_output_voltage' in target['expr']
439
+ else 'dac_val' if 'laser_servo_driver_dac_val' in target['expr'] else None)
440
+ if field:target['expr']=pzt_driver_query(field)
441
+ panel['description']='Latest measured driver sample per physical host at each time. Experiment shot_file labels do not create separate curves. Voltage is measured, not inferred from DAC.'
442
+ if quantity == 'controller.actions':
443
+ for target in panel['targets']:
444
+ if 'ldcon_applied' in target['expr'] or 'ldcon_action_event' in target['expr']:
445
+ target['expr'] = '(sum by(laser) (count_over_time(ldcon_action_event{' + SELECTOR + ',kind="command_applied"}[1m]))) or on(laser) (sum by(laser) (count_over_time(ldcon_applied{' + SELECTOR + ',kind="command_applied"}[1m])))'
446
+ defaults['noValue'] = 'No recorded commands'
447
+ panel['description'] = 'Recorded command_applied events. Native PI updates are not these events. No recorded commands does not mean zero activity or PI stopped.'
448
+ if quantity == 'optical.mode_hops':
449
+ defaults['noValue'] = 'No recorded hops'
450
+ panel['description'] = 'Confirmed recorded hop events in this interval. An absent event stream is not a measured zero or proof that no hop occurred.'
451
+ if 'Fringe fill factor' in panel['title']:
452
+ panel['description'] = 'Recorded fringe proxy only. F2 alone does not establish single-mode operation. Existing quality criteria are unchanged.'
453
+ if quantity == 'model.current_estimate':
454
+ panel['description'] = 'Bias setpoint plus nominal modulation transfer, with calibration uncertainty. This estimate is separate from measured diode current.'
455
+ groups[panel['type']].append((DISPLAY_ORDER.index(quantity), panel))
456
+ changes.append(dict(panel_id=panel['id'], old_title=old_title, title=panel['title'], action='shared ordering and numeric axis formatting'))
457
+ stats, timelines, series = ([p for _, p in sorted(groups[k], key=lambda pair: pair[0])]
458
+ for k in ('stat', 'state-timeline', 'timeseries'))
459
+ for i, panel in enumerate(stats):
460
+ row, col = divmod(i, 6); count = min(6, len(stats) - row * 6)
461
+ panel['gridPos'] = dict(x=col * 24 // count, y=row * 4, w=(col + 1) * 24 // count - col * 24 // count, h=4)
462
+ y = ((len(stats) + 5) // 6) * 4
463
+ for panel in timelines:
464
+ panel['gridPos'] = dict(x=0, y=y, w=24, h=4); y += 4
465
+ for i, panel in enumerate(series):
466
+ panel['gridPos'] = dict(x=i % 4 * 6, y=y + i // 4 * 8, w=6, h=8)
467
+ dashboard['panels'] = stats + timelines + series
468
+ if 'laser-detail' not in dashboard.setdefault('tags', []):
469
+ dashboard['tags'].append('laser-detail')
470
+ for variable in dashboard.get('templating', {}).get('list', []):
471
+ if variable['name'] == 'line_nm':
472
+ variable['hide'] = 2
473
+ if not any(link.get('url') == '/d/ldcon-fleet' for link in dashboard.get('links', [])):
474
+ dashboard.setdefault('links', []).append(dict(title='All lasers', type='link', url='/d/ldcon-fleet', targetBlank=False))
475
+ return dashboard, changes
476
+
477
+
478
+ def export_dashboard(directory,laser,p,*,reference_source='invocation',deck=None,card_parameters=None):
479
+ selection, bindings, _ = _report_selection(laser, p, deck, card_parameters)
480
+ dashboard=build_dashboard(laser,p,deck=deck,card_parameters=card_parameters)
481
+ directory=Path(directory);directory.mkdir(parents=True,exist_ok=True)
482
+ path=directory/'grafana-dashboard.json';path.write_text(json.dumps(dashboard,ensure_ascii=False,indent=2)+'\n')
483
+ manifest=dict(reference_wavelength_nm=p['reference_wavelength_nm'],reference_source=reference_source,
484
+ display_tolerance_hz=p.get('display_tolerance_hz'),laser=laser['name'],device_io=False,panels=len(dashboard['panels']),
485
+ report_selection=selection,metric_bindings={q:[dict(label=label,query=query) for label,query in queries] for q,queries in bindings.items()},
486
+ notes=['Quantities are declared by the selected deck cards; query/source bindings are a separate profile.',
487
+ 'Native current DAC/current estimates are distinct from measured current and ECDL PZT coordinates.',
488
+ 'Fringe and dither quantities are qualified pattern-dependent proxies, not universal mode-purity measurements.',
489
+ 'A missing series stays absent; export does not establish that a measurement exists or is fresh.',
490
+ 'No Grafana import, publication or device I/O is performed.'])
491
+ (directory/'grafana-manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n')
492
+ return dict(status='succeeded',artifacts=['grafana-dashboard.json','grafana-manifest.json'],output_writes=0,**manifest)