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.
- ldcon/__init__.py +302 -0
- ldcon/_moved.py +49 -0
- ldcon/agent_tools.py +4 -0
- ldcon/agents/__init__.py +1 -0
- ldcon/agents/adjustment_guidance.py +73 -0
- ldcon/agents/adjustment_quality.py +399 -0
- ldcon/agents/agent_adapters.py +709 -0
- ldcon/agents/agent_background.py +307 -0
- ldcon/agents/agent_bundle.py +75 -0
- ldcon/agents/agent_cards.py +171 -0
- ldcon/agents/agent_config_access.py +22 -0
- ldcon/agents/agent_configuration.py +224 -0
- ldcon/agents/agent_configuration_policy.py +67 -0
- ldcon/agents/agent_connect.py +93 -0
- ldcon/agents/agent_device_parameters.py +41 -0
- ldcon/agents/agent_execution.py +77 -0
- ldcon/agents/agent_history.py +112 -0
- ldcon/agents/agent_inbox_watch.py +121 -0
- ldcon/agents/agent_interactive.py +386 -0
- ldcon/agents/agent_interactive_hooks.py +381 -0
- ldcon/agents/agent_language.py +48 -0
- ldcon/agents/agent_log_context.py +308 -0
- ldcon/agents/agent_log_read.py +250 -0
- ldcon/agents/agent_log_watch.py +27 -0
- ldcon/agents/agent_metrics.py +111 -0
- ldcon/agents/agent_monitor.py +272 -0
- ldcon/agents/agent_opencode_http.py +270 -0
- ldcon/agents/agent_opencode_mcp.py +64 -0
- ldcon/agents/agent_owner_status.py +216 -0
- ldcon/agents/agent_profiles.py +186 -0
- ldcon/agents/agent_providers.py +53 -0
- ldcon/agents/agent_repairs.py +105 -0
- ldcon/agents/agent_routing.py +90 -0
- ldcon/agents/agent_runtime.py +70 -0
- ldcon/agents/agent_sandbox.py +141 -0
- ldcon/agents/agent_startup_notice.py +142 -0
- ldcon/agents/agent_task.py +336 -0
- ldcon/agents/agent_tools.py +488 -0
- ldcon/agents/agent_window.py +70 -0
- ldcon/agents/agent_workspace.py +340 -0
- ldcon/agents/card_fallback.py +284 -0
- ldcon/agents/dlc_pi_adjustment.py +250 -0
- ldcon/agents/dlc_pi_plan.py +24 -0
- ldcon/agents/dlc_pi_response_plan.py +26 -0
- ldcon/agents/failure_policy.py +473 -0
- ldcon/agents/fallback_service.py +146 -0
- ldcon/agents/fallback_targets.py +96 -0
- ldcon/agents/gateway_role.py +31 -0
- ldcon/agents/manual_adjustment_grant.py +66 -0
- ldcon/agents/measurement_grant.py +95 -0
- ldcon/agents/monitor_quality_binding.py +33 -0
- ldcon/agents/notification_policy.py +346 -0
- ldcon/agents/operating_mode_client.py +99 -0
- ldcon/agents/report_pdf.py +105 -0
- ldcon/agents/resident_adjustment.py +572 -0
- ldcon/agents/resident_agent.py +438 -0
- ldcon/agents/resident_quality_apply.py +233 -0
- ldcon/agents/task_decisions.py +218 -0
- ldcon/agents/teams_card.py +100 -0
- ldcon/agents/teams_destinations.py +157 -0
- ldcon/cld_owner.py +4 -0
- ldcon/cli.py +19 -0
- ldcon/core/__init__.py +1 -0
- ldcon/core/adjustment_plan.py +47 -0
- ldcon/core/adjustment_registry.py +100 -0
- ldcon/core/agent_authority.py +903 -0
- ldcon/core/card_admission.py +15 -0
- ldcon/core/card_definition.py +14 -0
- ldcon/core/card_reporting.py +238 -0
- ldcon/core/configuration.py +357 -0
- ldcon/core/configuration_transaction.py +225 -0
- ldcon/core/contracts.py +86 -0
- ldcon/core/current_settings.py +86 -0
- ldcon/core/deck_yaml.py +45 -0
- ldcon/core/device_conditions.py +133 -0
- ldcon/core/execution_context.py +3 -0
- ldcon/core/instrument_client.py +190 -0
- ldcon/core/interface_cards.py +520 -0
- ldcon/core/interface_schema.py +640 -0
- ldcon/core/laser_assets.py +238 -0
- ldcon/core/metric_quantities.py +38 -0
- ldcon/core/modulation.py +107 -0
- ldcon/core/operation_registry.py +195 -0
- ldcon/core/paths.py +26 -0
- ldcon/core/period_context.py +118 -0
- ldcon/core/records.py +4 -0
- ldcon/core/result_reservations.py +469 -0
- ldcon/core/site_bindings.py +25 -0
- ldcon/core/state_layout.py +70 -0
- ldcon/core/target_policy.py +79 -0
- ldcon/core/telegraf.py +106 -0
- ldcon/core/telemetry.py +130 -0
- ldcon/core/temperature_settings.py +12 -0
- ldcon/dashboard_metrics.py +4 -0
- ldcon/device_registry.py +4 -0
- ldcon/drivers/__init__.py +1 -0
- ldcon/drivers/cld1015.py +296 -0
- ldcon/drivers/cld1015_config.py +31 -0
- ldcon/drivers/dlcpro.py +177 -0
- ldcon/drivers/dlcpro_analog.py +33 -0
- ldcon/drivers/dlcpro_console.py +133 -0
- ldcon/drivers/dlcpro_priority.py +46 -0
- ldcon/drivers/ecdl.py +334 -0
- ldcon/drivers/esp32_servo.py +187 -0
- ldcon/drivers/fizeau.py +114 -0
- ldcon/drivers/frequency_sweep.py +69 -0
- ldcon/drivers/influx_wavelength.py +70 -0
- ldcon/drivers/metric_sources.py +82 -0
- ldcon/drivers/native_dither_telemetry.py +50 -0
- ldcon/drivers/rigol_scope.py +126 -0
- ldcon/drivers/siglent.py +418 -0
- ldcon/drivers/staircase.py +155 -0
- ldcon/drivers/wsu_udp.py +164 -0
- ldcon/esp32_time_telemetry.py +4 -0
- ldcon/example_config.py +41 -0
- ldcon/fallback_service.py +4 -0
- ldcon/instrument_owner.py +4 -0
- ldcon/instrument_service.py +4 -0
- ldcon/interface_worker.py +4 -0
- ldcon/job_service.py +4 -0
- ldcon/jobs/__init__.py +1 -0
- ldcon/jobs/adaptive_pid.py +234 -0
- ldcon/jobs/adaptive_window_session.py +922 -0
- ldcon/jobs/adjustment_continuation.py +284 -0
- ldcon/jobs/axis_frequency.py +219 -0
- ldcon/jobs/axis_frequency_report.py +86 -0
- ldcon/jobs/background_parameter_session.py +142 -0
- ldcon/jobs/background_pid_session.py +447 -0
- ldcon/jobs/branch_maps.py +71 -0
- ldcon/jobs/branch_target_migration.py +33 -0
- ldcon/jobs/card_runtime.py +157 -0
- ldcon/jobs/change_measure.py +293 -0
- ldcon/jobs/change_measure_adapter.py +360 -0
- ldcon/jobs/change_measure_dlc.py +94 -0
- ldcon/jobs/change_measure_parameters.py +222 -0
- ldcon/jobs/change_measure_target.py +135 -0
- ldcon/jobs/current_cards.py +159 -0
- ldcon/jobs/current_devices.py +71 -0
- ldcon/jobs/dashboard_api.py +61 -0
- ldcon/jobs/device_parameters.py +426 -0
- ldcon/jobs/dlc_pi_measurement.py +151 -0
- ldcon/jobs/dlcpro_awg_analysis.py +77 -0
- ldcon/jobs/dlcpro_awg_card.py +116 -0
- ldcon/jobs/dlcpro_cards.py +240 -0
- ldcon/jobs/ecdl_branch_hop.py +175 -0
- ldcon/jobs/ecdl_branch_recovery.py +176 -0
- ldcon/jobs/ecdl_devices.py +306 -0
- ldcon/jobs/ecdl_locked_sweep_proxy.py +526 -0
- ldcon/jobs/ecdl_mapped_recovery.py +6 -0
- ldcon/jobs/ecdl_mode_map.py +469 -0
- ldcon/jobs/ecdl_mode_recovery.py +20 -0
- ldcon/jobs/ecdl_online.py +158 -0
- ldcon/jobs/ecdl_operating_point.py +403 -0
- ldcon/jobs/ecdl_pi_commission.py +161 -0
- ldcon/jobs/ecdl_recovery.py +25 -0
- ldcon/jobs/ecdl_recovery_plan.py +90 -0
- ldcon/jobs/ecdl_recovery_test.py +59 -0
- ldcon/jobs/ecdl_relock.py +7 -0
- ldcon/jobs/ecdl_response_study.py +369 -0
- ldcon/jobs/ecdl_steady.py +343 -0
- ldcon/jobs/ecdl_thermal_context.py +242 -0
- ldcon/jobs/ecdl_window_center.py +734 -0
- ldcon/jobs/emission_card.py +102 -0
- ldcon/jobs/fringe_sweep_reference.py +89 -0
- ldcon/jobs/interface_devices.py +122 -0
- ldcon/jobs/interface_worker.py +471 -0
- ldcon/jobs/job_service.py +125 -0
- ldcon/jobs/laser_live.py +270 -0
- ldcon/jobs/learned_settling.py +450 -0
- ldcon/jobs/lock_target.py +461 -0
- ldcon/jobs/locked_triangle.py +36 -0
- ldcon/jobs/measurement_acquisition.py +51 -0
- ldcon/jobs/measurement_analysis.py +59 -0
- ldcon/jobs/measurement_execution.py +40 -0
- ldcon/jobs/measurement_notice.py +78 -0
- ldcon/jobs/measurement_presets.py +52 -0
- ldcon/jobs/measurement_report.py +76 -0
- ldcon/jobs/measurement_statistics.py +22 -0
- ldcon/jobs/metric_cards.py +34 -0
- ldcon/jobs/metric_interface.py +100 -0
- ldcon/jobs/modulation_sweep.py +274 -0
- ldcon/jobs/native_background_session.py +91 -0
- ldcon/jobs/native_current_acquire.py +143 -0
- ldcon/jobs/native_current_card.py +328 -0
- ldcon/jobs/native_current_online.py +129 -0
- ldcon/jobs/native_ff_proxy.py +1361 -0
- ldcon/jobs/native_ff_sweep.py +802 -0
- ldcon/jobs/online_pid.py +178 -0
- ldcon/jobs/operating_point.py +1399 -0
- ldcon/jobs/operating_point_proxy.py +236 -0
- ldcon/jobs/operation_execution.py +126 -0
- ldcon/jobs/operation_history.py +419 -0
- ldcon/jobs/optical_broker.py +23 -0
- ldcon/jobs/pi_algorithms.py +30 -0
- ldcon/jobs/pi_response.py +239 -0
- ldcon/jobs/progress.py +63 -0
- ldcon/jobs/quality_baseline.py +411 -0
- ldcon/jobs/recovery_decisions.py +131 -0
- ldcon/jobs/recovery_history.py +152 -0
- ldcon/jobs/recovery_procedure.py +285 -0
- ldcon/jobs/recovery_reference.py +123 -0
- ldcon/jobs/recovery_stationary.py +138 -0
- ldcon/jobs/response_algorithms.py +47 -0
- ldcon/jobs/response_comparison.py +62 -0
- ldcon/jobs/response_record.py +90 -0
- ldcon/jobs/runtime_parameters.py +434 -0
- ldcon/jobs/settling_profile.py +140 -0
- ldcon/jobs/shot_analysis.py +1300 -0
- ldcon/jobs/simulation.py +127 -0
- ldcon/jobs/slow_control.py +208 -0
- ldcon/jobs/slow_current.py +88 -0
- ldcon/jobs/study_adoption.py +122 -0
- ldcon/jobs/study_execution.py +182 -0
- ldcon/jobs/sweep_ff_analysis.py +320 -0
- ldcon/jobs/sweep_ff_optimization.py +202 -0
- ldcon/jobs/sweep_ff_plan.py +166 -0
- ldcon/jobs/sweep_ff_report.py +254 -0
- ldcon/jobs/sweep_ff_transport.py +326 -0
- ldcon/jobs/sweep_tracking.py +424 -0
- ldcon/jobs/sweep_verdict.py +288 -0
- ldcon/jobs/target_response.py +638 -0
- ldcon/jobs/target_response_analysis.py +65 -0
- ldcon/jobs/target_response_control.py +215 -0
- ldcon/jobs/target_response_decisions.py +152 -0
- ldcon/jobs/target_response_experiment.py +110 -0
- ldcon/jobs/target_response_feedforward.py +130 -0
- ldcon/jobs/target_response_search.py +225 -0
- ldcon/jobs/target_response_shift.py +475 -0
- ldcon/jobs/target_response_thermal.py +113 -0
- ldcon/jobs/target_response_thermal_experiment.py +120 -0
- ldcon/jobs/target_sequence.py +166 -0
- ldcon/jobs/temperature_identification.py +86 -0
- ldcon/jobs/wavelength_history.py +17 -0
- ldcon/jobs/window_actions.py +641 -0
- ldcon/jobs/window_center.py +784 -0
- ldcon/jobs/window_control.py +130 -0
- ldcon/jobs/window_decisions.py +105 -0
- ldcon/jobs/window_sampling.py +66 -0
- ldcon/jobs/workflow_artifacts.py +73 -0
- ldcon/migration/__init__.py +810 -0
- ldcon/migration/migration_decks.py +239 -0
- ldcon/migration/migration_readiness.py +397 -0
- ldcon/migration/migration_relocation.py +283 -0
- ldcon/monitor_owner.py +4 -0
- ldcon/owner_telemetry.py +4 -0
- ldcon/owners/__init__.py +1 -0
- ldcon/owners/adc_log_threshold.py +209 -0
- ldcon/owners/background_grants.py +44 -0
- ldcon/owners/cld_instrument_owner.py +96 -0
- ldcon/owners/cld_owner.py +706 -0
- ldcon/owners/cld_projection.py +194 -0
- ldcon/owners/cld_system_owner.py +276 -0
- ldcon/owners/current_owner.py +68 -0
- ldcon/owners/device_registry.py +229 -0
- ldcon/owners/dlcpro_awg_plan.py +115 -0
- ldcon/owners/dlcpro_awg_port.py +98 -0
- ldcon/owners/dlcpro_awg_protocol.py +190 -0
- ldcon/owners/dlcpro_awg_return.py +63 -0
- ldcon/owners/dlcpro_awg_sweep.py +437 -0
- ldcon/owners/dlcpro_awg_timing.py +35 -0
- ldcon/owners/dlcpro_awg_tracking.py +175 -0
- ldcon/owners/dlcpro_awg_tracking_guard.py +87 -0
- ldcon/owners/dlcpro_awg_tracking_report.py +49 -0
- ldcon/owners/dlcpro_awg_zero.py +82 -0
- ldcon/owners/dlcpro_deployment.py +66 -0
- ldcon/owners/dlcpro_ff.py +64 -0
- ldcon/owners/dlcpro_ff_adjustment.py +159 -0
- ldcon/owners/dlcpro_ff_delta_adjustment.py +194 -0
- ldcon/owners/dlcpro_frequency_window.py +61 -0
- ldcon/owners/dlcpro_observation.py +62 -0
- ldcon/owners/dlcpro_owner.py +830 -0
- ldcon/owners/dlcpro_pi_protocol.py +44 -0
- ldcon/owners/dlcpro_pi_trial.py +161 -0
- ldcon/owners/dlcpro_readback.py +50 -0
- ldcon/owners/dlcpro_service.py +203 -0
- ldcon/owners/dlcpro_target_changes.py +330 -0
- ldcon/owners/dlcpro_target_step.py +44 -0
- ldcon/owners/dlcpro_target_store.py +75 -0
- ldcon/owners/dlcpro_telemetry.py +72 -0
- ldcon/owners/ecdl_background_parameters.py +248 -0
- ldcon/owners/ecdl_background_pid.py +184 -0
- ldcon/owners/ecdl_hot_control.py +255 -0
- ldcon/owners/ecdl_independent_pid.py +95 -0
- ldcon/owners/ecdl_locked_study.py +122 -0
- ldcon/owners/ecdl_locked_sweep.py +1875 -0
- ldcon/owners/ecdl_native_pid.py +181 -0
- ldcon/owners/ecdl_optical.py +37 -0
- ldcon/owners/ecdl_owner.py +1058 -0
- ldcon/owners/ecdl_policy_reload.py +494 -0
- ldcon/owners/ecdl_prelock.py +612 -0
- ldcon/owners/ecdl_projection.py +636 -0
- ldcon/owners/ecdl_steady_owner.py +1859 -0
- ldcon/owners/ecdl_steady_policy.py +212 -0
- ldcon/owners/ecdl_target_ff.py +214 -0
- ldcon/owners/ecdl_target_settling.py +163 -0
- ldcon/owners/ecdl_tec_pair.py +88 -0
- ldcon/owners/ecdl_thermal_study.py +251 -0
- ldcon/owners/emission.py +365 -0
- ldcon/owners/emission_adapters.py +268 -0
- ldcon/owners/esp32_native_client.py +335 -0
- ldcon/owners/esp32_native_owner.py +2166 -0
- ldcon/owners/esp32_time_telemetry.py +253 -0
- ldcon/owners/esp32_udp_gate.py +136 -0
- ldcon/owners/external_sweep_guard.py +44 -0
- ldcon/owners/flexible_native_trial.py +172 -0
- ldcon/owners/fringe_peaks.py +92 -0
- ldcon/owners/full_data_log.py +133 -0
- ldcon/owners/instrument_identity.py +125 -0
- ldcon/owners/instrument_owner.py +1041 -0
- ldcon/owners/instrument_projection.py +104 -0
- ldcon/owners/instrument_service.py +638 -0
- ldcon/owners/migration_physical_lock.py +162 -0
- ldcon/owners/monitor_owner.py +322 -0
- ldcon/owners/native_assembly.py +37 -0
- ldcon/owners/native_assembly_host.py +260 -0
- ldcon/owners/native_background.py +233 -0
- ldcon/owners/native_current_tuning.py +251 -0
- ldcon/owners/native_ff_proof.py +433 -0
- ldcon/owners/native_instrument_owner.py +161 -0
- ldcon/owners/native_modulation_limits.py +45 -0
- ldcon/owners/native_policy_reload.py +186 -0
- ldcon/owners/native_policy_safety.py +213 -0
- ldcon/owners/native_policy_update.py +473 -0
- ldcon/owners/native_system_owner.py +420 -0
- ldcon/owners/operating_mode_owner.py +128 -0
- ldcon/owners/owner_projection.py +530 -0
- ldcon/owners/owner_telemetry.py +166 -0
- ldcon/owners/pi_handover.py +154 -0
- ldcon/owners/projection_transport.py +143 -0
- ldcon/owners/remote_instruments.py +323 -0
- ldcon/owners/sdg_client.py +122 -0
- ldcon/owners/sdg_owner.py +468 -0
- ldcon/owners/tec_rate.py +29 -0
- ldcon/owners/wsu_udp_monitor.py +113 -0
- ldcon/report_pdf.py +4 -0
- ldcon/resources/agent-model-options.json +142 -0
- ldcon/resources/agent_contract/adjustment.txt +45 -0
- ldcon/resources/agent_contract/common.txt +42 -0
- ldcon/resources/agent_contract/diagnosis.txt +11 -0
- ldcon/resources/agent_contract/research.txt +20 -0
- ldcon/resources/agent_contract/response_optimization.txt +13 -0
- ldcon/resources/examples/README.html +10 -0
- ldcon/resources/examples/assignments/sr1092.yaml +100 -0
- ldcon/resources/examples/assignments/sr422.yaml +677 -0
- ldcon/resources/examples/assignments/thf685.yaml +7 -0
- ldcon/resources/examples/interface-decks/dfb-dbr-ldc-tec.yaml +430 -0
- ldcon/resources/examples/interface-decks/ecdl-dlcpro.yaml +288 -0
- ldcon/resources/examples/interface-decks/ecdl-pzt-ldc-tec.yaml +698 -0
- ldcon/resources/examples/lasers/sr1092.yaml +267 -0
- ldcon/resources/examples/lasers/sr422.yaml +403 -0
- ldcon/resources/examples/lasers/thf685.yaml +158 -0
- ldcon/resources/report_template/ldconreport.cls +42 -0
- ldcon/resources/report_template/report.tex +47 -0
- ldcon/web/__init__.py +1 -0
- ldcon/web/agent_approval_api.py +126 -0
- ldcon/web/agent_settings_api.py +54 -0
- ldcon/web/agent_task_api.py +211 -0
- ldcon/web/control_readback.py +63 -0
- ldcon/web/dashboard_activity.py +109 -0
- ldcon/web/dashboard_controls_revision.py +29 -0
- ldcon/web/dashboard_metrics.py +146 -0
- ldcon/web/dashboard_snapshot.py +163 -0
- ldcon/web/dashboard_state.py +104 -0
- ldcon/web/dashboard_wavelength.py +150 -0
- ldcon/web/device_parameters_api.py +52 -0
- ldcon/web/dither_quality.py +46 -0
- ldcon/web/dlcpro_target_api.py +91 -0
- ldcon/web/dlcpro_target_query.py +23 -0
- ldcon/web/failure_api.py +62 -0
- ldcon/web/gateway_lan.py +208 -0
- ldcon/web/grafana_activity.py +84 -0
- ldcon/web/grafana_dashboard.py +492 -0
- ldcon/web/grafana_dlcpro.py +158 -0
- ldcon/web/grafana_quality.py +167 -0
- ldcon/web/interface_projection.py +27 -0
- ldcon/web/job_registry.py +68 -0
- ldcon/web/job_report.py +122 -0
- ldcon/web/laser_operations.py +445 -0
- ldcon/web/lock_target_api.py +554 -0
- ldcon/web/lock_target_queue.py +212 -0
- ldcon/web/log_archive.py +191 -0
- ldcon/web/metric_query_profiles.py +378 -0
- ldcon/web/online_position.py +26 -0
- ldcon/web/operating_mode_api.py +72 -0
- ldcon/web/operation_api.py +34 -0
- ldcon/web/operation_observation.py +57 -0
- ldcon/web/operations_settings.py +92 -0
- ldcon/web/quality_threshold_metrics.py +64 -0
- ldcon/web/shot_analysis_api.py +136 -0
- ldcon/web/sweep_api.py +1186 -0
- ldcon/web/sweep_guard.py +45 -0
- ldcon/web/target_reference_panel.py +38 -0
- ldcon/web/teams_destinations_api.py +81 -0
- ldcon/web/web_fleet.py +482 -0
- ldcon/web/web_runtime.py +811 -0
- ldcon/web/web_server.py +402 -0
- ldcon/web/window_api.py +28 -0
- ldcon/web_assets/agent-settings.css +4 -0
- ldcon/web_assets/agent-settings.html +53 -0
- ldcon/web_assets/agent-settings.js +270 -0
- ldcon/web_assets/app.css +9 -0
- ldcon/web_assets/app.js +802 -0
- ldcon/web_assets/card-browser.js +160 -0
- ldcon/web_assets/card-guide.js +9 -0
- ldcon/web_assets/cards/acquire.metrics.html +82 -0
- ldcon/web_assets/cards/agent.history_review.html +13 -0
- ldcon/web_assets/cards/agent.log_watch.html +1 -0
- ldcon/web_assets/cards/agent.monitor.html +13 -0
- ldcon/web_assets/cards/agent.request.html +1 -0
- ldcon/web_assets/cards/agent.resident.html +1 -0
- ldcon/web_assets/cards/agent.task.html +17 -0
- ldcon/web_assets/cards/commission.ecdl_pi.html +1 -0
- ldcon/web_assets/cards/identify.axis_frequency.html +1 -0
- ldcon/web_assets/cards/identify.current_response.html +1 -0
- ldcon/web_assets/cards/laser.emission.html +8 -0
- ldcon/web_assets/cards/measure.batch.html +1 -0
- ldcon/web_assets/cards/measure.change.html +1 -0
- ldcon/web_assets/cards/measure.target_response.html +1 -0
- ldcon/web_assets/cards/monitor.wavelength.html +1 -0
- ldcon/web_assets/cards/notify.teams.html +1 -0
- ldcon/web_assets/cards/optimize.settling_criteria.html +1 -0
- ldcon/web_assets/cards/optimize.target_feedforward.html +1 -0
- ldcon/web_assets/cards/optimize.target_step.html +1 -0
- ldcon/web_assets/cards/optimize.target_thermal.html +1 -0
- ldcon/web_assets/cards/plan.ecdl_recovery.html +14 -0
- ldcon/web_assets/cards/prepare.ecdl_recovery.html +18 -0
- ldcon/web_assets/cards/recover.ecdl_mode_hop.html +12 -0
- ldcon/web_assets/cards/report.grafana_dashboard.html +22 -0
- ldcon/web_assets/cards/servo.current_pid.html +1 -0
- ldcon/web_assets/cards/servo.dlcpro_frequency.html +1 -0
- ldcon/web_assets/cards/servo.ecdl_steady_lock.html +11 -0
- ldcon/web_assets/cards/servo.native_current.html +1 -0
- ldcon/web_assets/cards/study.ecdl_current_response.html +1 -0
- ldcon/web_assets/cards/study.ecdl_locked_current_map.html +1 -0
- ldcon/web_assets/cards/study.ecdl_mode_map.html +6 -0
- ldcon/web_assets/cards/study.ecdl_thermal_context.html +5 -0
- ldcon/web_assets/cards/study.target_response.html +1 -0
- ldcon/web_assets/cards/sweep.current_modulation.html +1 -0
- ldcon/web_assets/cards/sweep.dlcpro_frequency.html +1 -0
- ldcon/web_assets/cards/sweep.servo_modulation.html +1 -0
- ldcon/web_assets/cards/test.ecdl_recovery.html +4 -0
- ldcon/web_assets/cards/tune.ecdl_initial_point.html +1 -0
- ldcon/web_assets/cards/tune.ecdl_operating_point.html +13 -0
- ldcon/web_assets/cards/tune.ecdl_window_center.html +7 -0
- ldcon/web_assets/cards/tune.native_current.html +1 -0
- ldcon/web_assets/cards/tune.pid_coordinate_search.html +1 -0
- ldcon/web_assets/cards/tune.pid_factor_reduction.html +1 -0
- ldcon/web_assets/cards/tune.pid_online.html +1 -0
- ldcon/web_assets/cards/tune.temperature_once.html +18 -0
- ldcon/web_assets/committing-input.js +35 -0
- ldcon/web_assets/configuration-editor.js +194 -0
- ldcon/web_assets/dashboard-state.js +39 -0
- ldcon/web_assets/failures.html +1 -0
- ldcon/web_assets/failures.js +19 -0
- ldcon/web_assets/fallback.css +3 -0
- ldcon/web_assets/fallback.html +1 -0
- ldcon/web_assets/fallback.js +21 -0
- ldcon/web_assets/fleet-settings.js +20 -0
- ldcon/web_assets/fleet.css +9 -0
- ldcon/web_assets/fleet.html +8 -0
- ldcon/web_assets/fleet.js +130 -0
- ldcon/web_assets/gateway-presentation.js +27 -0
- ldcon/web_assets/grafana-help.js +31 -0
- ldcon/web_assets/help.html +22 -0
- ldcon/web_assets/i18n.js +279 -0
- ldcon/web_assets/index.html +14 -0
- ldcon/web_assets/laser-workspace.js +279 -0
- ldcon/web_assets/modulation.html +1 -0
- ldcon/web_assets/modulation.js +14 -0
- ldcon/web_assets/reservations.html +16 -0
- ldcon/web_assets/reservations.js +77 -0
- ldcon/web_assets/teams-settings.js +91 -0
- ldcon/web_assets/window-actions.js +37 -0
- ldcon/web_assets/workspace.css +99 -0
- ldcon/web_fleet.py +4 -0
- ldcon/web_server.py +4 -0
- ldcon-0.2.0.dist-info/METADATA +85 -0
- ldcon-0.2.0.dist-info/RECORD +482 -0
- ldcon-0.2.0.dist-info/WHEEL +5 -0
- ldcon-0.2.0.dist-info/entry_points.txt +5 -0
- ldcon-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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()
|