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,638 @@
1
+ """Linux AWG owner process. Does not open any instrument until the first I/O.
2
+
3
+ python -m ldcon.owners.instrument_service --config examples/instruments/repump1092-linux0.json
4
+ Use --simulate --bind 127.0.0.1 for tests. Real mode is opt-in by running this CLI.
5
+ """
6
+ import argparse
7
+ import base64
8
+ import binascii
9
+ import hmac
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ import json
12
+ import logging
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import threading
17
+ import uuid
18
+
19
+ from ldcon.drivers.siglent import DummyTransport, VisaTransport, fields
20
+ from ldcon.drivers.cld1015 import Controller
21
+ from ldcon.drivers.cld1015_config import configuration
22
+ from ldcon.core.instrument_client import BrokerError
23
+ from ldcon.core.device_conditions import ConditionError
24
+
25
+ MAX_REQUEST_BYTES = 64 * 1024 * 1024
26
+ _COMMAND = re.compile(r"C([12]):(BSWV|ARWV|BTWV|OUTP|MDWV|SWWV|SRATE|WVDT)(.*)", re.ASCII)
27
+
28
+
29
+ class SimulatedInstrument:
30
+ def __init__(self, config):
31
+ self.channels = {ch: DummyTransport(dict(channel=ch, profile=config["profile"],
32
+ min_v=-1, max_v=1, load="HZ")) for ch in config["channels"]}
33
+ self.closed = False
34
+
35
+ def _channel(self, command):
36
+ return self.channels[int(command[1:2])]
37
+
38
+ def query(self, command):
39
+ if command == "COUP?":
40
+ return "COUP TRACE,OFF,FCOUP,OFF,PCOUP,OFF,ACOUP,OFF"
41
+ if command in ("*IDN?", "*OPC?"):
42
+ return next(iter(self.channels.values())).query(command)
43
+ return self._channel(command).query(command)
44
+
45
+ def write(self, command):
46
+ self._channel(command).write(command)
47
+
48
+ def write_raw(self, data):
49
+ self._channel(data[:3].decode()).write_raw(data)
50
+
51
+ def close(self):
52
+ self.closed = True
53
+ for io in self.channels.values():
54
+ io.close()
55
+
56
+
57
+ class RigRoutedTransport:
58
+ """Broker transport for an AWG channel whose physical instrument is also used by an ECDL rig in this process:
59
+ every transaction runs inside ECDLOwner.shared_awg_session on the rig's one persistent session (rig lock held)."""
60
+ def __init__(self, owner, awg, connection=None):
61
+ self.owner, self.awg, self.connection = owner, awg, connection
62
+
63
+ def run(self, operations):
64
+ return self.owner.shared_awg_session(self.awg, list(operations), connection=self.connection)
65
+
66
+ def query(self, command):
67
+ return self.run([("query", command)])[0]
68
+
69
+ def write(self, command):
70
+ self.run([("write", command)])
71
+
72
+ def write_raw(self, data):
73
+ self.run([("write_raw", data)])
74
+
75
+ def close(self):
76
+ pass
77
+
78
+
79
+ class OwnerRelay:
80
+ """Rule G compatibility: a broker entry whose physical instrument is served by a per-instrument owner.
81
+
82
+ The lock worker of a running sr1092 job carries the frozen wiring ``endpoint :47130 / instrument
83
+ repump_1092 / resource TCPIP::192.168.1.37::INSTR``; this relay keeps that contract alive without any
84
+ session of its own: claim/channel_environment/transaction/release are forwarded to the owner, the owner's
85
+ lease token is the broker's lease token, and the owner's fault state is authoritative (the broker does not
86
+ latch a fault of its own for a relayed entry, so no instruments-service restart is needed to clear one)."""
87
+ def __init__(self, config, registry):
88
+ from ldcon.owners.instrument_identity import canonical
89
+ relay = config['relay_to_owner']
90
+ if not isinstance(relay, dict) or 'instrument' not in relay:
91
+ raise ValueError('relay_to_owner must be {instrument, endpoint?}')
92
+ self.instrument = canonical(relay['instrument'])
93
+ self.static_endpoint = relay.get('endpoint')
94
+ self.registry = registry
95
+ self.link = None
96
+
97
+ def _link(self):
98
+ if self.link is None:
99
+ from ldcon.owners.remote_instruments import OwnerLink
100
+ endpoint = self.registry.resolve(self.instrument, static_endpoint=self.static_endpoint)
101
+ self.link = OwnerLink(endpoint, self.instrument)
102
+ return self.link
103
+
104
+ def forward(self, action, **values):
105
+ try:
106
+ return self._link().rpc(action, **values)
107
+ except BrokerError:
108
+ raise
109
+ except Exception as exc:
110
+ self.link = None # the next call resolves the endpoint again (the owner may have restarted)
111
+ raise BrokerError('io', 'owner of %s unreachable: %s' % (self.instrument, exc)) from exc
112
+
113
+
114
+ class Instrument:
115
+ def __init__(self, config):
116
+ self.config = dict(config)
117
+ self.lock = threading.RLock()
118
+ self.leases = {}
119
+ self.io = None
120
+ self.fault = None
121
+ self.relay = None
122
+ self.shared_connection = None # Host object; never selected by an RPC field.
123
+ self.system_controller = None # Trusted local CLD system delegate; never populated by RPC.
124
+
125
+
126
+ class Hub:
127
+ def __init__(self, instruments, *, simulate=False, factory=None, ecdl_rigs=None, sensors=None, retained_instrument_faults=None,
128
+ registry=None):
129
+ from ldcon.drivers.staircase import PROFILES
130
+ from ldcon.owners.device_registry import RegistryClient
131
+ self.instruments = {}
132
+ resources = set()
133
+ # Rule G: how this process finds per-instrument owners (registry -> published state -> static endpoint).
134
+ self.registry = registry if isinstance(registry, RegistryClient) else RegistryClient(
135
+ (registry or {}).get('endpoint'), state_dir=(registry or {}).get('state_dir'))
136
+ for name, config in instruments.items():
137
+ if not name.isidentifier() or config["profile"] not in (*PROFILES, "cld1015"):
138
+ raise ValueError("invalid instrument name/profile")
139
+ config=dict(config)
140
+ # Rule M step 3 (2026-10-27): the Hub no longer reads a laser yaml named by ``temperature_conditions_file``
141
+ # (the indirect copy of the sr1092 CLD conditions). CLD operating conditions reach the CLD owner only as the
142
+ # laser system owner's projection; a stale ``temperature_conditions_file`` key is ignored here.
143
+ channels = config["channels"]
144
+ allowed = (0,) if config["profile"] == "cld1015" else (1, 2)
145
+ if not channels or any(ch not in allowed for ch in channels) or len(set(channels)) != len(channels):
146
+ raise ValueError("instrument channels must be unique members of [1, 2]")
147
+ resource = config["resource"].upper()
148
+ if resource in resources:
149
+ raise ValueError("each physical resource must have exactly one broker entry")
150
+ resources.add(resource)
151
+ self.instruments[name] = Instrument(config)
152
+ if config.get('relay_to_owner') is not None:
153
+ if config['profile'] == 'cld1015' or config.get('route_through_rig') or config.get('shared_with_rig'):
154
+ raise ValueError('relay_to_owner is for AWG entries and excludes route_through_rig/shared_with_rig')
155
+ self.instruments[name].relay = OwnerRelay(config, self.registry)
156
+ retained = retained_instrument_faults or {}
157
+ if not isinstance(retained, dict):raise ValueError('Retained faults must be a mapping')
158
+ for name, record in retained.items():
159
+ if (name not in self.instruments or not isinstance(record,dict)
160
+ or set(record)!={'resource','fault'}
161
+ or record['resource']!=self.instruments[name].config['resource']
162
+ or not isinstance(record['fault'],str) or not record['fault']):
163
+ raise ValueError('Retained fault identity mismatch')
164
+ for name, record in retained.items():self.instruments[name].fault=record['fault']
165
+ self.factory = factory or (SimulatedInstrument if simulate else self._visa)
166
+ self.simulate = simulate
167
+ self.ecdl_rigs={}
168
+ self.sensor_definitions={};self.sensor_ports={};self.sensor_lock=threading.RLock()
169
+ for name,entry in (sensors or {}).items():
170
+ from ldcon.core.interface_schema import load_mapping,validate_laser
171
+ laser=validate_laser(load_mapping(Path(entry['laser_file']).read_text()))
172
+ device=laser['devices'][entry['device']]
173
+ if device['type']!='fizeau.owner':raise ValueError('Optical owner requires a fizeau.owner definition')
174
+ self.sensor_definitions[name]=device
175
+ for name,entry in (ecdl_rigs or {}).items():
176
+ if simulate:raise ValueError('Real ECDL rig definitions cannot be loaded in AWG simulation mode')
177
+ from ldcon.owners.ecdl_owner import ECDLOwner
178
+ from ldcon.core.interface_schema import load_mapping
179
+ laser=load_mapping(Path(entry['laser_file']).read_text())
180
+ if name!=laser['id']:raise ValueError('ECDL rig identity mismatch')
181
+ self.ecdl_rigs[name]=ECDLOwner(laser,audit_path=entry['audit_file'],laser_file=entry['laser_file'],
182
+ single_session_awgs=entry.get('single_session_awgs',()),
183
+ instrument_owners=entry.get('instrument_owners'),registry=self.registry)
184
+ self.shared_lock = threading.Lock()
185
+ self.link_shared()
186
+
187
+ def link_shared(self):
188
+ """Shared physical AWG (2026-10-22): an instrument entry with ``shared_with_rig: <rig>`` (the sr1092
189
+ modulation channel CH2 of the SDG whose CH1 is the sr422 TEC) and that rig's locked sweep exclude each
190
+ other inside this one process: a claim is refused while the rig's sweep is in progress, and the rig's
191
+ locked_sweep_prepare is refused while such an instrument holds a lease. One lock orders both checks."""
192
+ for name, instrument in self.instruments.items():
193
+ shared = instrument.config.get('shared_with_rig')
194
+ if shared is not None and shared not in self.ecdl_rigs:
195
+ raise ValueError('shared_with_rig names an unknown ECDL rig: %s' % shared)
196
+ if shared is not None and not instrument.config.get('require_channel_environment'):
197
+ raise ValueError('A shared AWG entry must set require_channel_environment: true')
198
+ for name, instrument in self.instruments.items():
199
+ route = instrument.config.get('route_through_rig')
200
+ if route is not None:
201
+ if (not isinstance(route, dict) or set(route) != {'rig', 'awg'} or route['rig'] != instrument.config.get('shared_with_rig')
202
+ or route['rig'] not in self.ecdl_rigs
203
+ or route['awg'] not in getattr(self.ecdl_rigs[route['rig']], 'single_session_awgs', ())):
204
+ raise ValueError('route_through_rig must be {rig, awg} for the shared_with_rig rig, and that rig must hold the AWG in single_session_awgs')
205
+ for rig_name, owner in self.ecdl_rigs.items():
206
+ def leases(rig=rig_name):
207
+ return {n: [dict(l) for l in i.leases.values()] for n, i in self.instruments.items()
208
+ if i.config.get('shared_with_rig') == rig and i.leases}
209
+ owner.shared_guard = (self.shared_lock, leases)
210
+
211
+ def bind_shared_awg(self, name, authority, constraints, channels):
212
+ """Host composition only: bind a sibling system to its exact channels.
213
+
214
+ The ECDL physical connection verifies a real SystemPolicyAuthority and
215
+ owns the one shared guard/session. No wire request can create a binding.
216
+ """
217
+ instrument = self.instruments[name]
218
+ route = instrument.config.get('route_through_rig')
219
+ if route is None or set(channels) != set(instrument.config['channels']):
220
+ raise ValueError('A shared binding must name exactly the configured routed channels')
221
+ with instrument.lock:
222
+ if instrument.leases:
223
+ raise ValueError('Bind shared channels before admitting broker leases')
224
+ connection = self.ecdl_rigs[route['rig']].bind_shared_awg(
225
+ route['awg'], authority, constraints, channels)
226
+ instrument.shared_connection = connection
227
+ if isinstance(instrument.io, RigRoutedTransport):
228
+ instrument.io.connection = connection
229
+ return connection
230
+
231
+ def _open(self, instrument):
232
+ route = instrument.config.get('route_through_rig')
233
+ if route is not None:
234
+ return RigRoutedTransport(self.ecdl_rigs[route['rig']], route['awg'], instrument.shared_connection)
235
+ return self.factory(instrument.config)
236
+
237
+ def _rig_sweep_busy(self, rig):
238
+ owner = self.ecdl_rigs.get(rig)
239
+ sweep = getattr(getattr(owner, 'steady', None), 'sweep', None)
240
+ if sweep is None:
241
+ return None
242
+ from ldcon.owners.ecdl_locked_sweep import FINISHED
243
+ return None if sweep.state in FINISHED else sweep.state
244
+
245
+ @staticmethod
246
+ def _visa(config):
247
+ return VisaTransport(config["resource"], config.get("timeout_s", 10),
248
+ backend=config.get("visa_backend", "@py"))
249
+
250
+ def dispatch(self, request):
251
+ action = request["action"]
252
+ if action=='sensor_read':
253
+ from dataclasses import asdict
254
+ from ldcon.core.interface_schema import digest
255
+ from ldcon.drivers.fizeau import HttpFizeauWavemeter
256
+ device=self.sensor_definitions.get(request.get('instrument'))
257
+ if device is None or request.get('revision')!=digest(device):raise BrokerError('configuration','Optical sensor definition differs from owner')
258
+ name=request['instrument']
259
+ with self.sensor_lock:
260
+ if name not in self.sensor_ports:
261
+ c=device['connection']['source']
262
+ self.sensor_ports[name]=HttpFizeauWavemeter(c['endpoint'],channel=c['channel'],cycle=c['cycle'],
263
+ trust_source_clock=c['trust_source_clock'],expected_laser=c['expected_laser'])
264
+ return asdict(self.sensor_ports[name].read_frequency())
265
+ if action=='ecdl':
266
+ owner=self.ecdl_rigs.get(request.get('instrument'))
267
+ if owner is None:raise BrokerError('configuration','Unknown ECDL rig')
268
+ return owner.dispatch(request['request'])
269
+ if action == "status":
270
+ return self.status()
271
+ name = request["instrument"]
272
+ if name not in self.instruments:
273
+ raise BrokerError("unknown_instrument", str(name))
274
+ instrument = self.instruments[name]
275
+ with instrument.lock:
276
+ if instrument.relay is not None:
277
+ return self._relayed(instrument, request)
278
+ if action == "claim":
279
+ ch = request["channel"]
280
+ config = instrument.config
281
+ if ch not in config["channels"]:
282
+ raise BrokerError("channel", "channel is not served by this broker")
283
+ if request["resource"] != config["resource"] or request["profile"] != config["profile"]:
284
+ raise BrokerError("configuration", "client resource/profile does not match broker configuration")
285
+ if any(lease["channel"] == ch for lease in instrument.leases.values()):
286
+ raise BrokerError("busy", f"{name} CH{ch} is already leased")
287
+ shared = config.get("shared_with_rig")
288
+ token = uuid.uuid4().hex
289
+ if shared is not None:
290
+ with self.shared_lock:
291
+ busy = self._rig_sweep_busy(shared)
292
+ if busy is not None:
293
+ raise BrokerError("busy", f"{name} shares its AWG with rig {shared}, whose locked sweep is in progress ({busy})")
294
+ instrument.leases[token] = dict(channel=ch, owner=str(request.get("owner", "client"))[:200], environment_verified=False)
295
+ else:
296
+ instrument.leases[token] = dict(channel=ch, owner=str(request.get("owner", "client"))[:200])
297
+ return dict(lease=token, simulated=self.simulate)
298
+ if action == "release":
299
+ # Idempotent release permits a client to retry after a lost reply.
300
+ released=instrument.leases.pop(request['lease'],None)
301
+ if released and released['channel']==0 and isinstance(instrument.io,Controller):instrument.io.current_activation=None
302
+ return released is not None
303
+ if action == "controller":
304
+ lease = instrument.leases.get(request["lease"])
305
+ if lease is None or lease["channel"] != 0 or instrument.config["profile"] != "cld1015":
306
+ raise BrokerError("ownership", "controller requires its exclusive device lease")
307
+ if instrument.fault:
308
+ raise BrokerError("fault", instrument.fault)
309
+ method = request["method"]
310
+ if method not in ("read", "validate", "apply", "enable", "validate_sweep", "describe", "activate_current", "save_current_tuning"):
311
+ raise BrokerError("method", "unsupported controller operation")
312
+ if instrument.system_controller is not None:
313
+ from experiment_core.errors import AuthorizationRequired, StaleContext, Conflict
314
+ try:
315
+ return instrument.system_controller.dispatch(request)
316
+ except BrokerError:
317
+ raise
318
+ except (ValueError, ConditionError) as exc:
319
+ raise BrokerError('range', str(exc)) from exc
320
+ except (AuthorizationRequired, StaleContext, Conflict) as exc:
321
+ raise BrokerError('authorization', str(exc)) from exc
322
+ if method in ('apply', 'enable', 'activate_current', 'save_current_tuning'):
323
+ raise BrokerError('authorization', 'CLD mutations require a configured laser system owner')
324
+ if method=='describe':
325
+ from ldcon.core.device_conditions import policy_digest
326
+ policy=instrument.config.get('temperature_conditions')
327
+ return {'temperature_conditions':policy,'policy_digest':policy_digest(policy),
328
+ 'temperature_limits_c':instrument.config.get('temperature_limits_c'),
329
+ 'temperature_writes_enabled':False,
330
+ 'current_conditions':instrument.config.get('current_conditions'),
331
+ 'control_settings':instrument.config.get('control_settings',{}),
332
+ 'current_writes_enabled':False}
333
+ if method in ('apply','enable') and not instrument.config.get('temperature_writes_enabled',True):
334
+ raise BrokerError('range','Laser output writes are not enabled')
335
+ try:
336
+ if instrument.io is None:
337
+ cfg = instrument.config
338
+ policy = configuration(resource=cfg["resource"], mock=self.simulate,
339
+ current_limits_a=cfg.get("current_limits_a"),
340
+ temperature_limits_c=cfg.get("temperature_limits_c"),
341
+ modulation_role=cfg.get("modulation_role"), timeout_s=cfg.get("timeout_s", 5),
342
+ temperature_conditions=cfg.get("temperature_conditions"),
343
+ temperature_writes_enabled=cfg.get("temperature_writes_enabled",True),
344
+ current_conditions=cfg.get('current_conditions'),
345
+ control_settings=cfg.get('control_settings',{}),
346
+ current_writes_enabled=cfg.get('current_writes_enabled',False))
347
+ controller = Controller(policy)
348
+ instrument.io = controller
349
+ controller = instrument.io
350
+ if controller.io is None:
351
+ controller.connect()
352
+ if method=='activate_current':
353
+ from ldcon.owners.current_owner import activate
354
+ return activate(controller,request['policy_digest'],request['settings_digest'])
355
+ if method=='save_current_tuning':
356
+ from ldcon.owners.current_owner import save_tuning
357
+ return save_tuning(instrument,request['expected_digest'],request['proposal'])
358
+ if method == "read": return controller.read()
359
+ if method == "validate": return controller.validate(request["values"])
360
+ if method == "apply": return controller.apply(request["values"], ramp_s=request.get("ramp_s", 0))
361
+ if method == "enable": return controller.enable(request["output"], request["enabled"])
362
+ return controller.validate_sweep(request["channels"], {}, request["resolved"])
363
+ except (ValueError,ConditionError) as exc:
364
+ raise BrokerError("range", str(exc)) from exc
365
+ except Exception as exc:
366
+ instrument.fault = str(exc)
367
+ raise BrokerError("io", str(exc)) from exc
368
+ if action == "channel_environment":
369
+ return self._channel_environment(instrument, request)
370
+ if action != "transaction":
371
+ raise BrokerError("action", "unsupported request")
372
+ lease = instrument.leases.get(request["lease"])
373
+ if lease is None:
374
+ raise BrokerError("ownership", "no matching channel lease")
375
+ if instrument.fault:
376
+ raise BrokerError("fault", instrument.fault)
377
+ if instrument.config["profile"] == "cld1015":
378
+ raise BrokerError("operation", "use typed controller operations")
379
+ operations = request["operations"]
380
+ if not isinstance(operations, list) or not 1 <= len(operations) <= 64:
381
+ raise BrokerError("operations", "transaction needs 1..64 operations")
382
+ parsed = [self._validate(op, lease["channel"]) for op in operations]
383
+ if any(op != 'query' for op, _ in parsed):
384
+ route = instrument.config.get('route_through_rig')
385
+ if route is None or instrument.shared_connection is None:
386
+ raise BrokerError('authorization', 'Legacy AWG broker is read-only without a trusted system projection; use the physical instrument owner')
387
+ if (instrument.config.get("require_channel_environment") and not lease.get("environment_verified")
388
+ and any(op != "query" for op, _ in parsed)):
389
+ # Mandatory, no attestation route: the other channel of this physical AWG is safety-critical.
390
+ raise BrokerError("ownership", "verify channel_environment (identity, coupling/tracking OFF) on this lease before any write")
391
+ try:
392
+ if instrument.io is None:
393
+ instrument.io = self._open(instrument)
394
+ if isinstance(instrument.io, RigRoutedTransport):
395
+ return instrument.io.run(parsed) # one shared session, all operations, rig lock held
396
+ results = []
397
+ for op, data in parsed:
398
+ result = getattr(instrument.io, op)(data)
399
+ results.append(str(result).strip() if op == "query" else None)
400
+ return results
401
+ except Exception as exc:
402
+ # A timed-out query can leave a stale reply in the VISA stream.
403
+ # Never hand that stream to another channel or silently reconnect.
404
+ instrument.fault = f"I/O failed; restore/review instrument before restarting broker: {exc}"
405
+ raise BrokerError("io", instrument.fault) from exc
406
+
407
+ def _relayed(self, instrument, request):
408
+ """Forward the four lease actions of a relayed entry to its owner; same request/reply shapes."""
409
+ action = request["action"]
410
+ config = instrument.config
411
+ relay = instrument.relay
412
+ if action == "claim":
413
+ ch = request["channel"]
414
+ if ch not in config["channels"]:
415
+ raise BrokerError("channel", "channel is not served by this broker")
416
+ if request["resource"] != config["resource"] or request["profile"] != config["profile"]:
417
+ raise BrokerError("configuration", "client resource/profile does not match broker configuration")
418
+ result = relay.forward("claim", channel=ch, owner="relay:" + str(request.get("owner", "client"))[:150],
419
+ resource=config.get("owner_resource", config["resource"]), profile=config.get("owner_profile", config["profile"]))
420
+ instrument.leases[result["lease"]] = dict(channel=ch, owner=str(request.get("owner", "client"))[:200],
421
+ relayed_to=relay.instrument, environment_verified=False)
422
+ return dict(lease=result["lease"], simulated=False, relayed_to=relay.instrument)
423
+ if action == "release":
424
+ # Keep the compatibility lease when the owner refuses release or
425
+ # its response is unknown; no frontend state can clear that hold.
426
+ ok = relay.forward("release", lease=request["lease"])
427
+ released = instrument.leases.pop(request["lease"], None)
428
+ return bool(released) or bool(ok)
429
+ lease = instrument.leases.get(request.get("lease"))
430
+ if lease is None:
431
+ raise BrokerError("ownership", "no matching channel lease")
432
+ if action == "channel_environment":
433
+ result = relay.forward("channel_environment", lease=request["lease"])
434
+ lease["environment_verified"] = bool(result.get("ok"))
435
+ return result
436
+ projection = {key: request[key] for key in ('principal', 'receipt', 'expected_revision', 'cleanup') if key in request}
437
+ if action in ('projection_status', 'projection_install'):
438
+ return relay.forward(action, lease=request['lease'], **projection)
439
+ if action == "transaction":
440
+ operations = request["operations"]
441
+ if not isinstance(operations, list) or not 1 <= len(operations) <= 64:
442
+ raise BrokerError("operations", "transaction needs 1..64 operations")
443
+ for op in operations: # the owner validates addressing; refuse malformed shapes early
444
+ if not isinstance(op, dict) or op.get("op") not in ("query", "write", "write_raw"):
445
+ raise BrokerError("operation", "only query/write/write_raw are supported")
446
+ return relay.forward("transaction", lease=request["lease"], operations=operations, **projection)
447
+ raise BrokerError("action", "relayed entries support claim/release/channel_environment/projection_status/projection_install/transaction")
448
+
449
+ COUPLING_KEYS = ("TRACE", "FCOUP", "PCOUP", "ACOUP")
450
+
451
+ def _channel_environment(self, instrument, request):
452
+ """Read-only *IDN? and COUP? for a leased AWG channel. Verifies every coupling/tracking field is OFF and,
453
+ with ``expected_identity`` in the broker entry, the exact identity. Marks the lease verified for writes."""
454
+ lease = instrument.leases.get(request.get("lease"))
455
+ if lease is None or lease["channel"] not in (1, 2) or instrument.config["profile"] == "cld1015":
456
+ raise BrokerError("ownership", "AWG environment requires an AWG channel lease")
457
+ if instrument.fault:
458
+ raise BrokerError("fault", instrument.fault)
459
+ try:
460
+ if instrument.io is None:
461
+ instrument.io = self._open(instrument)
462
+ if isinstance(instrument.io, RigRoutedTransport):
463
+ identity, coupling = [str(v).strip() for v in instrument.io.run([("query", "*IDN?"), ("query", "COUP?")])]
464
+ else:
465
+ identity = str(instrument.io.query("*IDN?")).strip()
466
+ coupling = str(instrument.io.query("COUP?")).strip()
467
+ except Exception as exc:
468
+ instrument.fault = "AWG environment read failed; review required: " + str(exc)
469
+ raise BrokerError("io", instrument.fault) from exc
470
+ text = coupling[5:] if coupling.upper().startswith("COUP ") else coupling
471
+ values = fields(text)
472
+ reasons = []
473
+ expected = instrument.config.get("expected_identity")
474
+ if expected is not None and identity != str(expected).strip():
475
+ reasons.append("identity differs from the broker entry")
476
+ missing = [k for k in self.COUPLING_KEYS if k not in values]
477
+ if missing:
478
+ reasons.append("coupling reply lacks " + ", ".join(missing))
479
+ on = [k for k, v in values.items() if v.upper() == "ON"]
480
+ if any(values.get(k, "OFF").upper() != "OFF" for k in self.COUPLING_KEYS) or on:
481
+ reasons.append("coupling/tracking is not OFF: " + ", ".join(sorted(set(on))))
482
+ ok = not reasons
483
+ lease["environment_verified"] = ok
484
+ return dict(identity=identity, coupling=coupling, coupling_fields=values, ok=ok, reasons=reasons,
485
+ channel=lease["channel"], resource=instrument.config["resource"])
486
+
487
+ @staticmethod
488
+ def _validate(operation, channel):
489
+ op, data = operation["op"], operation["data"]
490
+ if op not in ("query", "write", "write_raw"):
491
+ raise BrokerError("operation", "only query/write/write_raw are supported")
492
+ payload = None
493
+ if op == "write_raw":
494
+ try:
495
+ payload = base64.b64decode(data, validate=True)
496
+ header, body = payload.split(b"WAVEDATA,", 1)
497
+ command = header.decode("ascii") + "WAVEDATA,"
498
+ except (ValueError, UnicodeError, binascii.Error) as exc:
499
+ raise BrokerError("binary", "invalid base64 WVDT upload") from exc
500
+ else:
501
+ command = data
502
+ if not isinstance(command, str) or not command.isascii() or any(ord(c) < 32 for c in command) or ";" in command:
503
+ raise BrokerError("command", "SCPI must be one ASCII command without separators")
504
+ if op == "query" and command in ("*IDN?", "*OPC?"):
505
+ return op, command
506
+ match = _COMMAND.fullmatch(command)
507
+ if match is None or int(match[1]) != channel:
508
+ raise BrokerError("ownership", "command is not addressed to the leased channel")
509
+ key, suffix = match[2], match[3]
510
+ if op == "query":
511
+ if suffix != "?" or key == "WVDT":
512
+ raise BrokerError("command", "unsupported query")
513
+ elif not suffix.startswith(" ") or "?" in suffix:
514
+ raise BrokerError("command", "invalid channel write")
515
+ if op == "write_raw":
516
+ if key != "WVDT":
517
+ raise BrokerError("binary", "binary operation must be WVDT")
518
+ try:
519
+ if int(fields(command)["LENGTH"]) != len(body):
520
+ raise ValueError("length mismatch")
521
+ except (KeyError, ValueError) as exc:
522
+ raise BrokerError("binary", "WVDT LENGTH does not match payload") from exc
523
+ elif key == "WVDT":
524
+ raise BrokerError("binary", "WVDT requires write_raw")
525
+ return op, payload if payload is not None else command
526
+
527
+ def status(self):
528
+ result = {}
529
+ for name, instrument in self.instruments.items():
530
+ with instrument.lock:
531
+ result[name] = dict(connected=(instrument.io.io is not None if isinstance(instrument.io, Controller) else instrument.io is not None), fault=instrument.fault,
532
+ leases=list(instrument.leases.values()),
533
+ write_authority=('typed_controller' if instrument.config['profile'] == 'cld1015'
534
+ else 'physical_owner_revalidation' if instrument.relay is not None
535
+ else 'trusted_shared_projection' if instrument.shared_connection is not None
536
+ else 'read_only_unprojected'))
537
+ if instrument.relay is not None:
538
+ result[name].update(relayed_to=instrument.relay.instrument, connected=instrument.relay.link is not None)
539
+ return dict(simulated=self.simulate, instruments=result,
540
+ ecdl_rigs={name:owner.dispatch({'action':'status'}) for name,owner in self.ecdl_rigs.items()})
541
+
542
+ def close(self, hold=True):
543
+ for owner in self.ecdl_rigs.values():
544
+ try:owner.close(hold=hold)
545
+ except Exception:logging.exception('rig close failed; continuing to release the other leases')
546
+ for instrument in self.instruments.values():
547
+ with instrument.lock:
548
+ if instrument.io is not None:
549
+ instrument.io.close()
550
+
551
+
552
+ def make_server(hub, bind="127.0.0.1", port=0, token=None):
553
+ class Handler(BaseHTTPRequestHandler):
554
+ def do_POST(self):
555
+ try:
556
+ if self.path != "/rpc":
557
+ raise BrokerError("path", "use /rpc")
558
+ if token and not hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer " + token):
559
+ raise BrokerError("authorization", "invalid broker token")
560
+ length = int(self.headers.get("Content-Length", "0"))
561
+ if not 0 < length <= MAX_REQUEST_BYTES:
562
+ raise BrokerError("size", "request is empty or exceeds 64 MiB")
563
+ self.connection.settimeout(30)
564
+ result = hub.dispatch(json.loads(self.rfile.read(length)))
565
+ reply = dict(ok=True, result=result)
566
+ except BrokerError as exc:
567
+ reply = dict(ok=False, code=exc.code, error=str(exc))
568
+ except (KeyError, ValueError, TypeError) as exc:
569
+ reply = dict(ok=False, code="request", error=str(exc))
570
+ except Exception:
571
+ logging.exception("Unhandled ldcon instrument service request")
572
+ reply = dict(ok=False, code="server", error="internal server error; inspect broker log")
573
+ payload = json.dumps(reply).encode()
574
+ self.send_response(200)
575
+ self.send_header("Content-Type", "application/json")
576
+ self.send_header("Content-Length", str(len(payload)))
577
+ self.end_headers()
578
+ try:
579
+ self.wfile.write(payload)
580
+ except (BrokenPipeError, ConnectionResetError):
581
+ logging.warning("Client lost reply; command may have executed; leases retained")
582
+
583
+ def log_message(self, fmt, *args):
584
+ logging.debug(fmt, *args)
585
+
586
+ return ThreadingHTTPServer((bind, port), Handler)
587
+
588
+
589
+ def main():
590
+ parser = argparse.ArgumentParser(description=__doc__)
591
+ parser.add_argument("--config", type=Path, required=True)
592
+ parser.add_argument("--bind")
593
+ parser.add_argument("--port", type=int)
594
+ parser.add_argument("--simulate", action="store_true")
595
+ args = parser.parse_args()
596
+ config = json.loads(args.config.read_text())
597
+ logging.basicConfig(level=logging.INFO)
598
+ # One broker process per Linux user. A second copy cannot open another VISA
599
+ # session even if started accidentally with a different listening port.
600
+ import fcntl
601
+ from ldcon.owners.migration_physical_lock import acquire_broker
602
+ with acquire_broker() as lock:
603
+ hub = Hub(config["instruments"], simulate=args.simulate,ecdl_rigs=config.get('ecdl_rigs'),sensors=config.get('sensors'),retained_instrument_faults=config.get('retained_instrument_faults'),
604
+ registry=config.get('registry'))
605
+ server = make_server(hub, args.bind or config["bind"],
606
+ args.port if args.port is not None else config["port"],
607
+ token=os.environ.get("LDCON_INSTRUMENT_TOKEN") or os.environ.get("AWG_BROKER_TOKEN"))
608
+ logging.info("ldcon instrument service %s; simulated=%s; instruments unopened", server.server_address, args.simulate)
609
+ terminated = install_termination(server)
610
+ try:
611
+ server.serve_forever()
612
+ except KeyboardInterrupt:
613
+ pass
614
+ finally:
615
+ server.server_close()
616
+ # SIGTERM (systemctl stop/restart): release this process's own owner leases ('ldcon rig ...' on the BK/SDG
617
+ # owners) without any hardware write. 2026-10-22 18:2xZ: without a handler the process died on SIGTERM,
618
+ # finally never ran, and the rig's leases stayed held, so the relock failed "CH1 is already leased".
619
+ hub.close(hold=not terminated.is_set())
620
+ logging.info("ldcon instrument service stopped; owner leases released (terminated=%s)", terminated.is_set())
621
+
622
+
623
+ def install_termination(server):
624
+ """SIGTERM -> stop serve_forever from a helper thread (shutdown() must not run in the serving thread), so the
625
+ finally block of main() runs and releases the owner leases. Returns the event that records the signal."""
626
+ import signal
627
+ import threading
628
+ terminated = threading.Event()
629
+
630
+ def handler(signum, frame):
631
+ terminated.set()
632
+ threading.Thread(target=server.shutdown, name='instrument-service-shutdown', daemon=True).start()
633
+ signal.signal(signal.SIGTERM, handler)
634
+ return terminated
635
+
636
+
637
+ if __name__ == "__main__":
638
+ main()