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,296 @@
1
+ """CLD1015 DOC-104674 Rev B, pp40-44: direct USBTMC/VISA, no DLL required."""
2
+ import math
3
+ import os
4
+ import hashlib
5
+ import threading
6
+ import time
7
+ from ldcon.drivers.cld1015_config import check
8
+ from ldcon.core.device_conditions import TemperatureConditions
9
+
10
+
11
+ class InvalidMeasurement(RuntimeError):
12
+ """Invalid numeric evidence; the caller must still track any prior write."""
13
+
14
+
15
+ class PhysicalLock:
16
+ """Keep the legacy resource flock while an owner replaces a read session."""
17
+ def __init__(self, resource):
18
+ if not resource or resource == "auto":
19
+ raise ValueError("Physical ownership requires a pinned VISA resource")
20
+ from ldcon.owners.migration_physical_lock import acquire_visa
21
+ self.resource = resource
22
+ self.handle = acquire_visa(resource)
23
+
24
+ def check(self, resource):
25
+ if self.handle.closed or self.resource != resource:
26
+ raise ValueError("Physical lock does not own the requested VISA resource")
27
+
28
+ def close(self):
29
+ self.handle.close()
30
+
31
+
32
+ class VisaIO:
33
+ def __init__(self, resource, timeout_s, *, physical_lock=None):
34
+ import pyvisa
35
+ self.rm = pyvisa.ResourceManager()
36
+ self.inst = None
37
+ self.process_lock = None
38
+ try:
39
+ if resource in (None, "auto"):
40
+ candidates = []
41
+ for address in self.rm.list_resources("USB?*::INSTR"):
42
+ try:
43
+ if int(address.split("::")[1], 0) == 0x1313:
44
+ candidates.append(address)
45
+ except (ValueError, IndexError):
46
+ continue
47
+ if len(candidates) != 1:
48
+ raise ValueError(f"Expected one Thorlabs USB VISA device, found {len(candidates)}; check USB/driver or specify resource")
49
+ resource = candidates[0]
50
+ if physical_lock is not None:
51
+ if not isinstance(physical_lock, PhysicalLock):
52
+ raise ValueError("Expected a held CLD1015 PhysicalLock")
53
+ physical_lock.check(resource)
54
+ elif os.name == "posix":
55
+ self.process_lock = PhysicalLock(resource)
56
+ self.inst = self.rm.open_resource(resource)
57
+ self.inst.timeout = int(timeout_s * 1000)
58
+ self.inst.read_termination = self.inst.write_termination = "\n"
59
+ try:
60
+ self.inst.lock_excl(timeout=int(timeout_s * 1000))
61
+ except NotImplementedError:
62
+ if self.process_lock is None and physical_lock is None:
63
+ raise
64
+ except pyvisa.errors.VisaIOError as exc:
65
+ if (self.process_lock is None and physical_lock is None) or exc.error_code != pyvisa.constants.StatusCode.error_nonsupported_operation:
66
+ raise
67
+ except Exception:
68
+ self.close()
69
+ raise
70
+
71
+ def query(self, command):
72
+ return self.inst.query(command).strip()
73
+
74
+ def write(self, command):
75
+ self.inst.write(command)
76
+
77
+ def close(self):
78
+ if self.inst is not None:
79
+ self.inst.close()
80
+ self.rm.close()
81
+ if self.process_lock is not None:
82
+ self.process_lock.close()
83
+ self.process_lock = None
84
+
85
+
86
+ class Controller:
87
+ def __init__(self, config, factory=None):
88
+ self.config, self.factory = config, factory
89
+ self.io = None
90
+ self.lock = threading.RLock()
91
+
92
+ def connect(self):
93
+ with self.lock:
94
+ if self.io is None:
95
+ self.io = (self.factory() if self.factory else MockIO() if self.config["mock"] else
96
+ VisaIO(self.config["resource"], self.config["timeout_s"]))
97
+ try:
98
+ if "CLD1015" not in self.io.query("*IDN?").upper():
99
+ raise RuntimeError("USB instrument is not a CLD1015")
100
+ except Exception:
101
+ self.close()
102
+ raise
103
+ return self.read()
104
+
105
+ def close(self):
106
+ with self.lock:
107
+ if self.io is not None:
108
+ self.io.close()
109
+ self.io = None
110
+
111
+ def query(self, command):
112
+ if self.io is None:
113
+ raise RuntimeError("Connect to CLD1015 first")
114
+ return self.io.query(command).strip()
115
+
116
+ def command(self, command):
117
+ self.io.write(command)
118
+ if self.query("*OPC?") != "1":
119
+ raise RuntimeError("CLD1015 command did not complete")
120
+ error = self.query("SYST:ERR?")
121
+ if int(error.split(",", 1)[0]) != 0:
122
+ raise RuntimeError(f"CLD1015: {error}")
123
+
124
+ def _unit(self):
125
+ unit = self.query("UNIT:TEMP?").upper()
126
+ if unit in ("C", "CEL", "CELSIUS"):
127
+ return 1., 0.
128
+ if unit in ("K", "KEL", "KELVIN"):
129
+ return 1., -273.15
130
+ if unit in ("F", "FAR", "FAHR", "FAHRENHEIT"):
131
+ return 5 / 9, -160 / 9
132
+ raise ValueError(f"Unknown CLD1015 temperature unit: {unit}")
133
+
134
+ def read(self):
135
+ with self.lock:
136
+ scale, offset = self._unit()
137
+ def number(cmd, field):
138
+ raw = self.query(cmd)
139
+ try:
140
+ value = float(raw)
141
+ except (ValueError, TypeError, OverflowError) as exc:
142
+ raise InvalidMeasurement(f"Invalid CLD1015 {field} ({cmd}): {raw!r}") from exc
143
+ if not math.isfinite(value):
144
+ raise InvalidMeasurement(f"Nonfinite CLD1015 {field} ({cmd}): {raw!r}")
145
+ return value
146
+ enabled = lambda cmd: self.query(cmd).upper() in ("1", "1.0", "ON")
147
+ data = dict(current_a=number("SOUR1:CURR?", "current_a"), current_read_a=number("SENS3:CURR:DATA?", "current_read_a"),
148
+ temperature_c=number("SOUR2:TEMP:SPO?", "temperature_c") * scale + offset,
149
+ temperature_read_c=number("SENS2:TEMP:DATA?", "temperature_read_c") * scale + offset,
150
+ tec_current_a=number("SOUR2:CURR:DATA?", "tec_current_a"),
151
+ current_limit_a=number("SOUR1:CURR:LIM?", "current_limit_a"),
152
+ ld_enabled=enabled("OUTP1?"),
153
+ tec_enabled=enabled("OUTP2?"),
154
+ modulation_enabled=enabled("SOUR1:AM:STAT?"),
155
+ ld_mode=self.query("SOUR1:FUNC:MODE?"), tec_mode=self.query("SOUR2:FUNC:MODE?"))
156
+ invalid = [key for key, value in data.items() if isinstance(value, float) and not math.isfinite(value)]
157
+ if invalid:
158
+ raise InvalidMeasurement("Nonfinite converted CLD1015 fields: " + ", ".join(invalid))
159
+ return data
160
+
161
+ def validate(self, request):
162
+ if not request or set(request) - {"current_a", "temperature_c"}:
163
+ raise ValueError("Setpoints must contain current_a and/or temperature_c only")
164
+ state = self.read()
165
+ if "current_a" in request:
166
+ value = check(request["current_a"], self.config["current_limits_a"], "current")
167
+ if state["ld_mode"].upper() not in ("CURR", "CURRENT"):
168
+ raise ValueError("LD must already be in constant-current mode")
169
+ if not 0 <= value <= state["current_limit_a"]:
170
+ raise ValueError("Requested current exceeds controller current limit")
171
+ if "temperature_c" in request:
172
+ value = check(request["temperature_c"], self.config["temperature_limits_c"], "temperature")
173
+ if state["tec_mode"].upper() not in ("TEMP", "TEMPERATURE"):
174
+ raise ValueError("TEC must already be in temperature mode")
175
+ scale, offset = self._unit()
176
+ lo = float(self.query("SOUR2:TEMP:LIM:LOW?")) * scale + offset
177
+ hi = float(self.query("SOUR2:TEMP:LIM:UPP?")) * scale + offset
178
+ if not lo <= value <= hi:
179
+ raise ValueError("Temperature exceeds controller temperature limits")
180
+ return state
181
+
182
+ def apply(self, request, *, ramp_s=0):
183
+ with self.lock:
184
+ if not self.config.get('temperature_writes_enabled',True):raise ValueError('Laser output writes are not enabled')
185
+ if self.config.get('current_conditions') is not None and 'current_a' in request:
186
+ from ldcon.owners.current_owner import apply_current
187
+ return apply_current(self,request,ramp_s)
188
+ before = self.validate(request) # Validate the whole request before writing.
189
+ ramp_s = float(ramp_s)
190
+ if not math.isfinite(ramp_s) or not 0 <= ramp_s <= 3600:
191
+ raise ValueError("ramp_s must be finite in 0..3600 s")
192
+ policy = self.config.get('temperature_conditions')
193
+ guard = TemperatureConditions(policy,self.config.get('control_settings')) if policy is not None and 'temperature_c' in request else None
194
+ delta_temperature = abs(float(request.get('temperature_c', before['temperature_c']))-before['temperature_c'])
195
+ if guard and not guard.low<=float(request['temperature_c'])<=guard.high:
196
+ raise ValueError('Final temperature exceeds absolute device envelope')
197
+ if guard and delta_temperature and ramp_s<=0:
198
+ raise ValueError('Temperature device policy requires a timed ramp')
199
+ steps = max(1, math.ceil(ramp_s / .1))
200
+ if guard:
201
+ steps=max(1,math.ceil(ramp_s/.5),math.ceil(max(0.,delta_temperature-1e-12)/guard.step))
202
+ interval=self.config.get('control_settings',{}).get('temperature',{}).get('output_update_interval_s')
203
+ if interval is not None:
204
+ interval=float(interval)
205
+ if not math.isfinite(interval) or not .1<=interval<=2:raise ValueError('Invalid device temperature update interval')
206
+ steps=max(1,math.ceil(max(0.,delta_temperature-1e-12)/guard.step))
207
+ if ramp_s+1e-9<steps*interval:raise ValueError('Temperature ramp is faster than device update interval')
208
+ scale, offset = self._unit()
209
+ previous_temperature=before['temperature_c'];previous_time=time.monotonic()
210
+ for step in range(1, steps + 1):
211
+ if ramp_s:time.sleep(ramp_s/steps)
212
+ candidate=before['temperature_c']+(float(request.get('temperature_c',before['temperature_c']))-before['temperature_c'])*step/steps
213
+ if guard:
214
+ observed=self.read()
215
+ state={'setpoint_c':observed['temperature_c'],'temperature_c':observed['temperature_read_c'],
216
+ 'tec_current_a':observed['tec_current_a'],'current_a':observed['current_read_a'],'ld_enabled':observed['ld_enabled'],'tec_enabled':observed['tec_enabled']}
217
+ if abs(observed['temperature_c']-previous_temperature)>.002:
218
+ raise ValueError('External temperature setpoint change detected')
219
+ # No client-supplied elapsed time; use the owner process clock.
220
+ guard.check(state,candidate,time.monotonic()-previous_time,previous=previous_temperature)
221
+ for key in ('current_a','temperature_c'):
222
+ if key in request:
223
+ value = before[key] + (float(request[key]) - before[key]) * step / steps
224
+ native = value if key == 'current_a' else (value - offset) / scale
225
+ command = 'SOUR1:CURR' if key == 'current_a' else 'SOUR2:TEMP:SPO'
226
+ self.command(f'{command} {native:.12g}')
227
+ if key=='temperature_c':previous_temperature=value;previous_time=time.monotonic()
228
+ result = self.read()
229
+ for key in request:
230
+ tolerance = 6e-5 if key == "current_a" else .002
231
+ if abs(result[key] - float(request[key])) > tolerance:
232
+ raise RuntimeError(f"CLD1015 setpoint verification failed: {key}")
233
+ return result
234
+
235
+ def enable(self, output, enabled):
236
+ with self.lock:
237
+ if output not in ("ld", "tec", "modulation") or not isinstance(enabled, bool):
238
+ raise ValueError("Select ld/tec/modulation and a boolean state")
239
+ state = self.read()
240
+ if enabled:
241
+ self.validate(dict(current_a=state["current_a"], temperature_c=state["temperature_c"]))
242
+ if output == "tec" and not enabled and state["ld_enabled"]:
243
+ raise ValueError("Turn LD off explicitly before turning TEC off")
244
+ command = {"ld": "OUTP1", "tec": "OUTP2", "modulation": "SOUR1:AM:STAT"}[output]
245
+ self.command(f"{command} {int(enabled)}")
246
+ result=self.read()
247
+ key={'ld':'ld_enabled','tec':'tec_enabled','modulation':'modulation_enabled'}[output]
248
+ if result[key] is not enabled:
249
+ raise RuntimeError('CLD1015 output enable verification failed: '+output)
250
+ return result
251
+
252
+ def validate_sweep(self, channels, waves, resolved):
253
+ state = self.read()
254
+ if not state["ld_enabled"] or not state["modulation_enabled"] or state["ld_mode"].upper() not in ("CURR", "CURRENT"):
255
+ raise ValueError("CLD1015 sweep requires LD on, constant-current mode and modulation enabled")
256
+ role = self.config["modulation_role"]
257
+ for c, actual in zip(channels, resolved):
258
+ if c["role"] != role:
259
+ continue
260
+ lo, hi = actual["offset_v"] - actual["amplitude_vpp"] / 2, actual["offset_v"] + actual["amplitude_vpp"] / 2
261
+ # DOC-104674 p56: 150 mA/V +/-5%. Check the total LD current,
262
+ # including the actual AWG baseline and controller DC setpoint.
263
+ extremes = [state["current_a"] + v * gain for v in (lo, hi) for gain in (.1425, .1575)]
264
+ for value in extremes:
265
+ check(value, self.config["current_limits_a"], "current")
266
+ if not 0 <= value <= state["current_limit_a"]:
267
+ raise ValueError("AWG envelope exceeds CLD1015 current limit")
268
+ return
269
+ raise ValueError("Embedded CLD1015 modulation_role does not match a sweep channel")
270
+
271
+
272
+ class MockIO:
273
+ def __init__(self, unit="C"):
274
+ self.unit, self.log, self.closed = unit, [], False
275
+ self.state = {"SOUR1:CURR": .05, "SOUR2:TEMP:SPO": 25., "OUTP1": 1, "OUTP2": 1,
276
+ "SOUR1:AM:STAT": 1}
277
+
278
+ def query(self, command):
279
+ self.log.append(command)
280
+ if command == "*IDN?": return "THORLABS,CLD1015,DUMMY,1"
281
+ if command == "*OPC?": return "1"
282
+ if command == "SYST:ERR?": return '0,"No error"'
283
+ if command == "UNIT:TEMP?": return self.unit
284
+ fixed = {"SOUR1:CURR:LIM?": .1, "SOUR2:TEMP:LIM:LOW?": 10.,
285
+ "SOUR2:TEMP:LIM:UPP?": 40., "SOUR1:FUNC:MODE?": "CURR", "SOUR2:FUNC:MODE?": "TEMP",
286
+ "SENS3:CURR:DATA?": self.state["SOUR1:CURR"],
287
+ "SENS2:TEMP:DATA?": self.state["SOUR2:TEMP:SPO"], "SOUR2:CURR:DATA?": .01}
288
+ return str(fixed[command] if command in fixed else self.state[command[:-1]])
289
+
290
+ def write(self, command):
291
+ self.log.append(command)
292
+ key, value = command.split()
293
+ self.state[key] = float(value)
294
+
295
+ def close(self):
296
+ self.closed = True
@@ -0,0 +1,31 @@
1
+ """Compile-time configuration; SI current and degrees Celsius at the public API."""
2
+ import math
3
+
4
+
5
+ def limits(value, name):
6
+ if value is None:
7
+ return None
8
+ result = tuple(float(v) for v in value)
9
+ if len(result) != 2 or not all(math.isfinite(v) for v in result) or result[0] >= result[1]:
10
+ raise ValueError(f"{name} must be an increasing finite pair")
11
+ return result
12
+
13
+
14
+ def configuration(resource=None, *, current_limits_a=None, temperature_limits_c=None,
15
+ timeout_s=5, mock=False, modulation_role=None,
16
+ endpoint=None, instrument=None, temperature_conditions=None, temperature_writes_enabled=True, current_conditions=None, control_settings=None, current_writes_enabled=False, ld_enable_writes_enabled=False):
17
+ if type(ld_enable_writes_enabled) is not bool:
18
+ raise ValueError('ld_enable_writes_enabled must be a boolean')
19
+ return dict(resource=resource, current_limits_a=limits(current_limits_a, "current_limits_a"),
20
+ temperature_limits_c=limits(temperature_limits_c, "temperature_limits_c"),
21
+ timeout_s=float(timeout_s), mock=bool(mock), modulation_role=modulation_role,
22
+ endpoint=endpoint, instrument=instrument, temperature_conditions=temperature_conditions, temperature_writes_enabled=temperature_writes_enabled, current_conditions=current_conditions, control_settings=control_settings or {}, current_writes_enabled=current_writes_enabled, ld_enable_writes_enabled=ld_enable_writes_enabled)
23
+
24
+
25
+ def check(value, bounds, name):
26
+ value = float(value)
27
+ if bounds is None:
28
+ raise ValueError(f"configure {name} safety limits before changing the setpoint")
29
+ if not math.isfinite(value) or not bounds[0] <= value <= bounds[1]:
30
+ raise ValueError(f"{name} {value} outside {bounds}")
31
+ return value
@@ -0,0 +1,177 @@
1
+ """DLC Pro 1.9/3.2 USER console adapter; no connection is opened on import.
2
+
3
+ Command reference pp. 38-43, 51-61, 118-119, 262. Exactly one console
4
+ connection belongs to the instrument owner. Never retry a write after timeout.
5
+ """
6
+ import json
7
+ import math
8
+ import re
9
+
10
+ from ldcon.core.contracts import DeviceError
11
+
12
+
13
+ from ldcon.drivers.dlcpro_console import Console, UncertainDLCWrite
14
+
15
+
16
+ class DLCParameterError(DeviceError):
17
+ def __init__(self, path, code):
18
+ super().__init__(f'DLC parameter {path} returned error {code}')
19
+ self.path, self.code = path, code
20
+
21
+
22
+ def _name(path):
23
+ if not isinstance(path, str) or not re.fullmatch(r'[a-z][a-z0-9:-]*', path):
24
+ raise ValueError('Invalid DLC parameter name')
25
+ return path
26
+
27
+
28
+ def _atom(value):
29
+ if type(value) is bool:
30
+ return '#t' if value else '#f'
31
+ if type(value) not in (int, float) or not math.isfinite(value):
32
+ raise ValueError('DLC writes require finite numbers or booleans')
33
+ return repr(value)
34
+
35
+
36
+ def parse_atom(text):
37
+ text = text.strip()
38
+ if text in ('#t', '#f'):
39
+ return text == '#t'
40
+ if text.startswith('"') and text.endswith('"'):
41
+ return json.loads(text)
42
+ try:
43
+ value = float(text)
44
+ except ValueError:
45
+ raise DeviceError('Unexpected DLC scalar reply') from None
46
+ if not math.isfinite(value):
47
+ raise DeviceError('Nonfinite DLC scalar reply')
48
+ return value
49
+
50
+
51
+ class DLCPro:
52
+ """Unit-preserving parameter access. There is deliberately no eval/exec RPC."""
53
+ AXES = {'piezo': ('pc:voltage-set', 'pc:voltage-act', 'V'),
54
+ 'temperature': ('tc:temp-set', 'tc:temp-act', 'degC'),
55
+ 'current': ('cc:current-offset', 'cc:current-act', 'mA')}
56
+
57
+ def __init__(self, console):
58
+ self.console = console
59
+ self.unavailable_parameters = {}
60
+ self.compact_write_supported = False
61
+
62
+ @staticmethod
63
+ def path(channel, suffix):
64
+ if type(channel) is not int or channel not in (1, 2, 3, 4):
65
+ raise ValueError('Invalid DLC laser channel')
66
+ return _name(f'laser{channel}:dl:{suffix}')
67
+
68
+ def read(self, path):
69
+ path = _name(path)
70
+ reply = self.console.command(f"(param-ref '{path})")
71
+ error = re.fullmatch(r'Error: (-?\d+)(?: .*)?', reply)
72
+ if error:
73
+ raise DLCParameterError(path, int(error[1]))
74
+ return parse_atom(reply)
75
+
76
+ def read_optional(self, path):
77
+ """Only an explicit unknown/unavailable parameter means unsupported.
78
+
79
+ Transport failure, malformed data, and access denial still propagate.
80
+ The negative capability cache lives only for this connection.
81
+ """
82
+ if path in self.unavailable_parameters:
83
+ return None
84
+ try:
85
+ return self.read(path)
86
+ except DLCParameterError as error:
87
+ if error.code not in (-3, -20):
88
+ raise
89
+ self.unavailable_parameters[path] = error.code
90
+ return None
91
+
92
+ def identity(self, channel):
93
+ self.path(channel, 'serial-number') # Validate before building names.
94
+ return dict(system_type=self.read('system-type'),
95
+ serial_number=self.read('serial-number'),
96
+ firmware_version=self.read('fw-ver'),
97
+ product_name=self.read(f'laser{channel}:product-name'),
98
+ head_serial_number=self.read(self.path(channel, 'serial-number')))
99
+
100
+ def verify_identity(self, serial_number, firmware_version=None):
101
+ identity = dict(serial_number=self.read('serial-number'),
102
+ firmware_version=self.read('fw-ver'))
103
+ if identity['serial_number'] != serial_number:
104
+ raise DeviceError('DLC chassis identity mismatch')
105
+ if firmware_version is not None and identity['firmware_version'] != firmware_version:
106
+ raise DeviceError('DLC firmware differs from commissioned version')
107
+ return identity
108
+
109
+ def control_transaction(self):
110
+ return self.console.lock
111
+
112
+ def verify_compact_write_support(self):
113
+ # Constants only: this startup capability check reads/writes no outputs.
114
+ reply = self.console.command('(let ((status 0)) (list status 12.5))')
115
+ if self._pair(reply) != (0., 12.5):
116
+ raise DeviceError('DLC ordered result/readback expression unsupported')
117
+ self.compact_write_supported = True
118
+
119
+ @staticmethod
120
+ def _pair(reply):
121
+ match = re.fullmatch(r'\(\s*([^\s()]+)\s+([^\s()]+)\s*\)', reply.strip())
122
+ if not match:
123
+ raise DeviceError('DLC verification pair malformed')
124
+ return parse_atom(match[1]), parse_atom(match[2])
125
+
126
+ def write(self, path, value, *, tolerance=0., compact=False):
127
+ # The acknowledgement and readback form one indivisible transaction.
128
+ # Low-priority monitoring may not interleave between them.
129
+ with self.console.lock:
130
+ return self._verified_write(path, value, tolerance=tolerance, compact=compact)
131
+
132
+ def _verified_write(self, path, value, *, tolerance, compact):
133
+ path = _name(path)
134
+ # Any response other than exact success needs review. Code 2 is CLIPPED,
135
+ # not success; do not accept even if its subsequent readback happens to match.
136
+ if compact:
137
+ if not self.compact_write_supported:
138
+ raise DeviceError('Compact write capability was not verified')
139
+ # let forces param-set! to finish before param-ref. Merely putting
140
+ # both calls in list arguments would leave Scheme evaluation order unspecified.
141
+ expression = f"(let ((status (param-set! '{path} {_atom(value)}))) (list status (param-ref '{path})))"
142
+ else:
143
+ expression = f"(param-set! '{path} {_atom(value)})"
144
+ reply = self.console.command(expression, writing=True)
145
+ try:
146
+ status, actual = self._pair(reply) if compact else (parse_atom(reply), None)
147
+ if type(status) is bool or status != 0:
148
+ raise ValueError('DLC rejected, clipped or warned on write')
149
+ if not compact:
150
+ actual = self.read(path)
151
+ if type(value) is bool:
152
+ equal = actual is value
153
+ else:
154
+ equal = type(actual) is not bool and abs(actual - value) <= tolerance
155
+ if not equal:
156
+ raise ValueError('DLC setpoint readback mismatch')
157
+ except Exception as error:
158
+ raise UncertainDLCWrite('DLC write not verified; inspection required') from error
159
+ return actual
160
+
161
+ def snapshot(self, channel):
162
+ values = {}
163
+ for axis, (setting, measured, unit) in self.AXES.items():
164
+ values[axis] = dict(setpoint=self.read(self.path(channel, setting)),
165
+ measured=self.read(self.path(channel, measured)), unit=unit)
166
+ values['current']['total_setpoint'] = self.read(self.path(channel, 'cc:current-set'))
167
+ values['piezo']['calculated_output'] = self.read_optional(self.path(channel, 'pc:voltage-out'))
168
+ values['piezo']['calculated_output_available'] = values['piezo']['calculated_output'] is not None
169
+ values['feedforward_master'] = self.read_optional(f'laser{channel}:feedforward-master')
170
+ values['unavailable_parameters'] = dict(self.unavailable_parameters)
171
+ values['emission'] = self.read(self.path(channel, 'cc:emission'))
172
+ values['pzt_enabled'] = self.read(self.path(channel, 'pc:enabled'))
173
+ values['tec_enabled'] = self.read(self.path(channel, 'tc:enabled'))
174
+ values['tec_fault'] = self.read(self.path(channel, 'tc:fault'))
175
+ values['ff_enabled'] = self.read(self.path(channel, 'cc:feedforward-enabled'))
176
+ values['ff_factor_ma_per_v'] = self.read(self.path(channel, 'cc:feedforward-factor'))
177
+ return values
@@ -0,0 +1,33 @@
1
+ """Documented Fine inputs and ARC readback, using the owner's console.
2
+
3
+ Fine readbacks have no device acquisition timestamp. The host request interval
4
+ is retained and must not be presented as a synchronous ADC/WSU acquisition.
5
+ """
6
+ import time
7
+
8
+
9
+ INPUTS = {'fine1': ('io:fine-1:value-act', 0),
10
+ 'fine2': ('io:fine-2:value-act', 1)}
11
+
12
+
13
+ def inspect(driver, channel, *, clock=time.monotonic, utc=time.time):
14
+ result = {'inputs': {}, 'routes': {}}
15
+ for name, (path, _) in INPUTS.items():
16
+ start, wall = clock(), utc()
17
+ value = driver.read(path)
18
+ result['inputs'][name] = dict(value_v=value, started_s=start,
19
+ completed_s=clock(), started_unix_s=wall, completed_unix_s=utc(),
20
+ acquisition_timestamp_available=False)
21
+ for axis in ('pc', 'cc', 'tc'):
22
+ result['routes'][axis] = {key: driver.read(driver.path(channel,
23
+ f'{axis}:external-input:{key}')) for key in ('enabled', 'signal', 'factor')}
24
+ return result
25
+
26
+
27
+ def read_input(driver, name, *, clock=time.monotonic, utc=time.time):
28
+ path, _ = INPUTS[name]
29
+ start, wall = clock(), utc()
30
+ value = driver.read(path)
31
+ return dict(value_v=value, started_s=start, completed_s=clock(),
32
+ started_unix_s=wall, completed_unix_s=utc(),
33
+ acquisition_timestamp_available=False)
@@ -0,0 +1,133 @@
1
+ """Bounded DLC Telnet framing, without telnetlib or terminal emulation."""
2
+ import socket
3
+ import time
4
+ from ldcon.drivers.dlcpro_priority import PriorityLock
5
+
6
+ from ldcon.core.contracts import DeviceError
7
+
8
+
9
+ class UncertainDLCWrite(DeviceError):
10
+ """A write may have reached the controller; never replay automatically."""
11
+
12
+
13
+ class Console:
14
+ def __init__(self, stream, *, max_reply_bytes=262144, timeout_s=2.):
15
+ self.stream = stream
16
+ self.max_reply_bytes = max_reply_bytes
17
+ self.timeout_s = timeout_s
18
+ self.lock = PriorityLock()
19
+ self.broken = False
20
+ self._telnet_state = 'data'
21
+ self._verb = None
22
+ self.greeting = self._reply()
23
+
24
+ @classmethod
25
+ def connect(cls, host, *, port=1998, timeout_s=2.):
26
+ stream = socket.create_connection((host, port), timeout=timeout_s)
27
+ stream.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
28
+ try:
29
+ console = cls(stream, timeout_s=timeout_s)
30
+ # FW 1.9 echoes each character despite rejecting Telnet ECHO.
31
+ # This console-local setting avoids per-character reply overhead;
32
+ # it changes no laser parameter and leaves other consoles alone.
33
+ if console.command("(param-set! 'echo #f)") != '0':
34
+ raise DeviceError('DLC console echo disable rejected')
35
+ if console.command("(param-ref 'echo)") != '#f':
36
+ raise DeviceError('DLC console echo disable not verified')
37
+ return console
38
+ except BaseException:
39
+ stream.close()
40
+ raise
41
+
42
+ def _decode_telnet(self, raw):
43
+ text = bytearray()
44
+ for byte in raw:
45
+ state = self._telnet_state
46
+ if state == 'data':
47
+ if byte == 255:
48
+ self._telnet_state = 'iac'
49
+ else:
50
+ text.append(byte)
51
+ elif state == 'iac':
52
+ if byte == 255:
53
+ text.append(byte)
54
+ self._telnet_state = 'data'
55
+ elif byte in (251, 252, 253, 254):
56
+ self._verb = byte
57
+ self._telnet_state = 'option'
58
+ elif byte == 250:
59
+ self._telnet_state = 'subnegotiation'
60
+ else:
61
+ self._telnet_state = 'data'
62
+ elif state == 'option':
63
+ # We need no terminal options. Reject WILL with DONT and DO with
64
+ # WONT; acknowledgments are ignored to avoid negotiation loops.
65
+ if self._verb in (251, 253):
66
+ self.stream.sendall(bytes((255, 254 if self._verb == 251 else 252, byte)))
67
+ self._telnet_state = 'data'
68
+ elif state == 'subnegotiation':
69
+ if byte == 255:
70
+ self._telnet_state = 'subnegotiation_iac'
71
+ elif state == 'subnegotiation_iac':
72
+ self._telnet_state = 'data' if byte == 240 else 'subnegotiation'
73
+ return text
74
+
75
+ def _reply(self):
76
+ result = bytearray()
77
+ received = 0
78
+ deadline = time.monotonic() + self.timeout_s
79
+ while not ((result == b'> ' or result.endswith(b'\n> '))
80
+ and self._telnet_state == 'data'):
81
+ remaining = deadline - time.monotonic()
82
+ if remaining <= 0:
83
+ raise TimeoutError('DLC reply deadline exceeded')
84
+ if hasattr(self.stream, 'settimeout'):
85
+ self.stream.settimeout(remaining)
86
+ block = self.stream.recv(4096)
87
+ if not block:
88
+ raise ConnectionError('DLC console disconnected before prompt')
89
+ self._quick_ack()
90
+ received += len(block)
91
+ if received > self.max_reply_bytes:
92
+ raise DeviceError('DLC reply exceeds framing bound')
93
+ result.extend(self._decode_telnet(block))
94
+ return result[:-2].replace(b'\r\x00', b'\r').decode('utf-8').strip()
95
+
96
+ def _quick_ack(self):
97
+ # Linux QUICKACK is transient: request it for each received fragment.
98
+ # This can unblock a peer's small remaining reply/prompt under Nagle;
99
+ # our TCP_NODELAY only controls requests sent from this socket. It is
100
+ # an optional transport optimization, never a change to reply bounds.
101
+ option = getattr(socket, 'TCP_QUICKACK', None)
102
+ configure = getattr(self.stream, 'setsockopt', None)
103
+ if option is not None and configure is not None:
104
+ try:
105
+ configure(socket.IPPROTO_TCP, option, 1)
106
+ except OSError:
107
+ pass # Unsupported socket/platform keeps the original framing.
108
+
109
+ def command(self, expression, *, writing=False):
110
+ if not isinstance(expression, str) or any(c in expression for c in '\r\n\x00'):
111
+ raise ValueError('DLC command must be one line')
112
+ with self.lock:
113
+ if self.broken:
114
+ raise DeviceError('DLC console requires inspection/reconnection')
115
+ try:
116
+ if hasattr(self.stream, 'settimeout'):
117
+ self.stream.settimeout(self.timeout_s)
118
+ self.stream.sendall((expression + '\n').encode('ascii'))
119
+ reply = self._reply()
120
+ lines = reply.splitlines()
121
+ # Firmware 1.9 may echo even after ECHO negotiation is rejected.
122
+ # Remove only the exact request on its own first line.
123
+ if lines and lines[0] == expression:
124
+ reply = '\n'.join(lines[1:]).strip()
125
+ return reply
126
+ except Exception as error:
127
+ self.broken = True
128
+ if writing:
129
+ raise UncertainDLCWrite('DLC write outcome unknown; no replay') from error
130
+ raise DeviceError('DLC read failed') from error
131
+
132
+ def close(self):
133
+ self.stream.close()