Files
RadioPhotonic_PCB_PC_software/laser_control/controller.py
2026-04-26 18:39:55 +03:00

597 lines
22 KiB
Python

"""High-level controller orchestrating protocol encoding and serial transport."""
from __future__ import annotations
import logging
import math
import time
from typing import Callable, Sequence
from .constants import (
AD9102_CLOCK_HZ,
AD9102_PAT_BASE_MAX,
AD9102_PAT_BASE_MIN,
AD9102_PAT_PERIOD_MAX,
AD9102_PAT_PERIOD_MIN,
AD9102_SAW_STEP_MAX,
AD9102_SAW_STEP_MIN,
AD9102_SRAM_AMPLITUDE_MAX,
AD9102_SRAM_AMPLITUDE_MIN,
AD9102_SRAM_HOLD_MAX,
AD9102_SRAM_HOLD_MIN,
AD9102_SRAM_SAMPLE_MAX,
AD9102_SRAM_SAMPLE_MIN,
AD9102_WAVE_MAX_CHUNK_SAMPLES,
AD9102_WAVE_SAMPLE_MAX,
AD9102_WAVE_SAMPLE_MIN,
AD9833_FREQ_WORD_MAX,
AD9833_FREQ_WORD_MIN,
AD9833_MCLK_HZ,
AD9833_OUTPUT_FREQ_MAX_HZ,
AD9833_OUTPUT_FREQ_MIN_HZ,
DEFAULT_CURRENT1_MA,
DEFAULT_AD9102_HOLD_CYCLES,
DEFAULT_AD9102_PAT_BASE,
DEFAULT_AD9102_PAT_PERIOD,
DEFAULT_CURRENT2_MA,
DEFAULT_PI_I,
DEFAULT_PI_P,
DEFAULT_TEMP1_C,
DEFAULT_TEMP2_C,
DS1809_COUNT_MAX,
DS1809_COUNT_MIN,
DS1809_PULSE_MS_MAX,
DS1809_PULSE_MS_MIN,
GET_DATA_TOTAL_LENGTH,
PROFILE_SAVE_DATA_CHUNK_BYTES,
PROFILE_SAVE_SECTION_PROFILE_TEXT,
PROFILE_SAVE_SECTION_WAVEFORM_TEXT,
STM32_DAC_CODE_MAX,
STM32_DAC_CODE_MIN,
STATUS_RESPONSE_LENGTH,
WAIT_AFTER_SEND_SEC,
)
from .exceptions import (
CommunicationError,
DeviceNotRespondingError,
DeviceStateError,
InvalidParameterError,
)
from .models import DeviceState, DeviceStatus, Measurements, ProfileSaveRequest
from .protocol import Protocol
from .transport import SerialTransport
from .validators import ParameterValidator
logger = logging.getLogger(__name__)
_AD9102_SAW_RAMP_STEPS = 1 << 14
def ad9102_saw_frequency_limits_hz(*, triangle: bool) -> tuple[int, int]:
"""Return the reachable frequency range for the built-in saw generator."""
factor = 2 if triangle else 1
minimum = math.ceil(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * AD9102_SAW_STEP_MAX))
maximum = math.floor(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * AD9102_SAW_STEP_MIN))
return minimum, maximum
def ad9102_saw_frequency_from_step_hz(*, triangle: bool, saw_step: int) -> float:
"""Calculate the actual built-in saw/triangle frequency for a given SAW_STEP."""
factor = 2 if triangle else 1
saw_step = max(AD9102_SAW_STEP_MIN, min(AD9102_SAW_STEP_MAX, int(saw_step)))
return AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * saw_step)
def ad9102_saw_step_from_frequency_hz(*, triangle: bool, frequency_hz: int) -> tuple[int, float]:
"""Map a desired built-in saw frequency to the closest supported SAW_STEP."""
min_hz, max_hz = ad9102_saw_frequency_limits_hz(triangle=triangle)
if frequency_hz < min_hz or frequency_hz > max_hz:
raise InvalidParameterError(
f"frequency_hz must be in range [{min_hz}, {max_hz}] for this AD9102 mode"
)
factor = 2 if triangle else 1
saw_step = round(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * frequency_hz))
saw_step = max(AD9102_SAW_STEP_MIN, min(AD9102_SAW_STEP_MAX, saw_step))
return saw_step, ad9102_saw_frequency_from_step_hz(triangle=triangle, saw_step=saw_step)
def ad9102_sram_frequency_limits_hz(*, hold_cycles: int = DEFAULT_AD9102_HOLD_CYCLES) -> tuple[int, int]:
"""Return the reachable frequency range for SRAM playback for a fixed hold setting."""
hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES
minimum = math.ceil(AD9102_CLOCK_HZ / (AD9102_SRAM_SAMPLE_MAX * hold))
maximum = math.floor(AD9102_CLOCK_HZ / (AD9102_SRAM_SAMPLE_MIN * hold))
return minimum, maximum
def ad9102_sram_frequency_from_playback_hz(*, sample_count: int, hold_cycles: int) -> float:
"""Calculate the actual SRAM playback frequency."""
sample_count = max(AD9102_SRAM_SAMPLE_MIN, min(AD9102_SRAM_SAMPLE_MAX, int(sample_count)))
hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES
hold = max(DEFAULT_AD9102_HOLD_CYCLES, min(AD9102_SRAM_HOLD_MAX, int(hold)))
return AD9102_CLOCK_HZ / (sample_count * hold)
def ad9102_sram_sample_count_from_frequency_hz(
*,
frequency_hz: int,
hold_cycles: int = DEFAULT_AD9102_HOLD_CYCLES,
) -> tuple[int, float]:
"""Map a desired SRAM playback frequency to the closest supported sample count."""
min_hz, max_hz = ad9102_sram_frequency_limits_hz(hold_cycles=hold_cycles)
if frequency_hz < min_hz or frequency_hz > max_hz:
raise InvalidParameterError(
f"frequency_hz must be in range [{min_hz}, {max_hz}] for this AD9102 mode"
)
hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES
sample_count = round(AD9102_CLOCK_HZ / (frequency_hz * hold))
sample_count = max(AD9102_SRAM_SAMPLE_MIN, min(AD9102_SRAM_SAMPLE_MAX, sample_count))
return sample_count, ad9102_sram_frequency_from_playback_hz(
sample_count=sample_count,
hold_cycles=hold,
)
class LaserController:
"""Public API for manual control, polling, and status queries."""
def __init__(
self,
port: str | None = None,
pi_coeff1_p: int = DEFAULT_PI_P,
pi_coeff1_i: int = DEFAULT_PI_I,
pi_coeff2_p: int = DEFAULT_PI_P,
pi_coeff2_i: int = DEFAULT_PI_I,
on_data: Callable[[Measurements], None] | None = None,
) -> None:
self._transport = SerialTransport(port=port)
self._pi1_p = pi_coeff1_p
self._pi1_i = pi_coeff1_i
self._pi2_p = pi_coeff2_p
self._pi2_i = pi_coeff2_i
self._on_data = on_data
self._message_id = 0
self._last_measurements: Measurements | None = None
self._last_temp1 = DEFAULT_TEMP1_C
self._last_temp2 = DEFAULT_TEMP2_C
self._last_current1 = DEFAULT_CURRENT1_MA
self._last_current2 = DEFAULT_CURRENT2_MA
@property
def is_connected(self) -> bool:
"""Return True when the serial port is connected."""
return self._transport.is_connected
@property
def port_name(self) -> str | None:
"""Return the active serial port name when available."""
return self._transport.port_name
def connect(self) -> bool:
"""Open the configured serial connection."""
self._transport.connect()
logger.info("Connected to laser controller on port %s", self.port_name)
return True
def disconnect(self) -> None:
"""Close the serial connection."""
self._transport.disconnect()
logger.info("Disconnected from laser controller")
def set_manual_mode(
self,
temp1: float,
temp2: float,
current1: float,
current2: float,
) -> None:
"""Send manual setpoints and remember them for post-reset restore."""
values = ParameterValidator.validate_manual_mode_params(
temp1=temp1,
temp2=temp2,
current1=current1,
current2=current2,
)
self._message_id = (self._message_id + 1) & 0xFFFF
command = Protocol.encode_decode_enable(
temp1=values["temp1"],
temp2=values["temp2"],
current1=values["current1"],
current2=values["current2"],
pi_coeff1_p=self._pi1_p,
pi_coeff1_i=self._pi1_i,
pi_coeff2_p=self._pi2_p,
pi_coeff2_i=self._pi2_i,
message_id=self._message_id,
)
self._send_and_expect_ok(command)
self._last_temp1 = values["temp1"]
self._last_temp2 = values["temp2"]
self._last_current1 = values["current1"]
self._last_current2 = values["current2"]
def reset(self) -> None:
"""Send DEFAULT_ENABLE and require an error-free acknowledgement."""
self._send_and_expect_ok(Protocol.encode_default_enable())
logger.info("Device reset command sent")
def configure_ad9102(
self,
*,
enabled: bool,
use_sram: bool,
triangle: bool = False,
saw_step: int = 1,
pat_period_base: int = 2,
pat_period: int = 0xFFFF,
sample_count: int = 16,
hold_cycles: int = 1,
amplitude: int = 8191,
use_amplitude_format: bool = False,
) -> int:
"""Configure the AD9102 signal generator in saw or generated-SRAM mode."""
if use_sram:
sample_count = self._validate_int_range(
sample_count,
"sample_count",
AD9102_SRAM_SAMPLE_MIN,
AD9102_SRAM_SAMPLE_MAX,
)
if use_amplitude_format:
amplitude = self._validate_int_range(
amplitude,
"amplitude",
AD9102_SRAM_AMPLITUDE_MIN,
AD9102_SRAM_AMPLITUDE_MAX,
)
command = Protocol.encode_ad9102_control(
enabled=enabled,
triangle=triangle,
sram_mode=True,
alt_format=True,
param0=amplitude,
param1=sample_count,
)
else:
hold_cycles = self._validate_int_range(
hold_cycles,
"hold_cycles",
AD9102_SRAM_HOLD_MIN,
AD9102_SRAM_HOLD_MAX,
)
command = Protocol.encode_ad9102_control(
enabled=enabled,
triangle=triangle,
sram_mode=True,
alt_format=False,
param0=sample_count,
param1=hold_cycles,
)
else:
saw_step = self._validate_int_range(
saw_step,
"saw_step",
AD9102_SAW_STEP_MIN,
AD9102_SAW_STEP_MAX,
)
pat_period_base = self._validate_int_range(
pat_period_base,
"pat_period_base",
AD9102_PAT_BASE_MIN,
AD9102_PAT_BASE_MAX,
)
pat_period = self._validate_int_range(
pat_period,
"pat_period",
AD9102_PAT_PERIOD_MIN,
AD9102_PAT_PERIOD_MAX,
)
param0 = ((pat_period_base & 0x0F) << 8) | (saw_step & 0xFF)
command = Protocol.encode_ad9102_control(
enabled=enabled,
triangle=triangle,
sram_mode=False,
alt_format=False,
param0=param0,
param1=pat_period,
)
detail = self._send_and_expect_ok(command)
logger.info("AD9102 configured: sram=%s triangle=%s enabled=%s", use_sram, triangle, enabled)
return detail
def configure_ad9102_simple(
self,
*,
enabled: bool,
use_sram: bool,
triangle: bool,
frequency_hz: int,
amplitude: int = 8191,
) -> dict[str, float | int | bool]:
"""Configure AD9102 using simplified frequency/shape controls."""
if use_sram:
amplitude = self._validate_int_range(
amplitude,
"amplitude",
AD9102_SRAM_AMPLITUDE_MIN,
AD9102_SRAM_AMPLITUDE_MAX,
)
sample_count, actual_frequency_hz = ad9102_sram_sample_count_from_frequency_hz(
frequency_hz=frequency_hz,
hold_cycles=DEFAULT_AD9102_HOLD_CYCLES,
)
detail = self.configure_ad9102(
enabled=enabled,
use_sram=True,
triangle=triangle,
sample_count=sample_count,
amplitude=amplitude,
use_amplitude_format=True,
)
return {
"detail": detail,
"actual_frequency_hz": actual_frequency_hz,
"sample_count": sample_count,
"hold_cycles": DEFAULT_AD9102_HOLD_CYCLES,
"amplitude_applied": True,
}
saw_step, actual_frequency_hz = ad9102_saw_step_from_frequency_hz(
triangle=triangle,
frequency_hz=frequency_hz,
)
detail = self.configure_ad9102(
enabled=enabled,
use_sram=False,
triangle=triangle,
saw_step=saw_step,
pat_period_base=DEFAULT_AD9102_PAT_BASE,
pat_period=DEFAULT_AD9102_PAT_PERIOD,
)
return {
"detail": detail,
"actual_frequency_hz": actual_frequency_hz,
"saw_step": saw_step,
"amplitude_applied": False,
}
def configure_ad9833(self, *, enabled: bool, triangle: bool, frequency_word: int) -> None:
"""Configure the AD9833 generator using its raw 28-bit frequency word."""
frequency_word = self._validate_int_range(
frequency_word,
"frequency_word",
AD9833_FREQ_WORD_MIN,
AD9833_FREQ_WORD_MAX,
)
self._send_and_expect_ok(
Protocol.encode_ad9833_control(
enabled=enabled,
triangle=triangle,
frequency_word=frequency_word,
)
)
logger.info("AD9833 configured: enabled=%s triangle=%s word=%d", enabled, triangle, frequency_word)
def configure_ad9833_frequency(self, *, enabled: bool, triangle: bool, frequency_hz: int) -> int:
"""Configure AD9833 using output frequency in hertz for a 20 MHz master clock."""
frequency_hz = self._validate_int_range(
frequency_hz,
"frequency_hz",
AD9833_OUTPUT_FREQ_MIN_HZ,
AD9833_OUTPUT_FREQ_MAX_HZ,
)
frequency_word = int(round(frequency_hz * (1 << 28) / AD9833_MCLK_HZ))
if frequency_word < AD9833_FREQ_WORD_MIN:
frequency_word = AD9833_FREQ_WORD_MIN
if frequency_word > AD9833_FREQ_WORD_MAX:
frequency_word = AD9833_FREQ_WORD_MAX
self.configure_ad9833(
enabled=enabled,
triangle=triangle,
frequency_word=frequency_word,
)
return frequency_word
def pulse_ds1809(self, *, increment: bool, count: int, pulse_ms: int) -> None:
"""Pulse the DS1809 digital potentiometer in one direction."""
if not increment and count:
decrement = True
else:
decrement = False
count = self._validate_int_range(count, "count", DS1809_COUNT_MIN, DS1809_COUNT_MAX)
pulse_ms = self._validate_int_range(
pulse_ms,
"pulse_ms",
DS1809_PULSE_MS_MIN,
DS1809_PULSE_MS_MAX,
)
self._send_and_expect_ok(
Protocol.encode_ds1809_control(
increment=increment,
decrement=decrement,
count=count,
pulse_ms=pulse_ms,
)
)
logger.info("DS1809 pulsed: increment=%s count=%d pulse_ms=%d", increment, count, pulse_ms)
def set_stm32_dac(self, *, enabled: bool, dac_code: int) -> None:
"""Set the STM32 on-chip DAC code and output-enable state."""
dac_code = self._validate_int_range(
dac_code,
"dac_code",
STM32_DAC_CODE_MIN,
STM32_DAC_CODE_MAX,
)
self._send_and_expect_ok(
Protocol.encode_stm32_dac_control(enabled=enabled, dac_code=dac_code)
)
logger.info("STM32 DAC configured: enabled=%s code=%d", enabled, dac_code)
def save_profile_to_sd(self, request: ProfileSaveRequest) -> None:
"""Stream a rendered profile INI and optional waveform CSV to the device SD card."""
if not isinstance(request, ProfileSaveRequest):
raise InvalidParameterError("request", "Value must be a ProfileSaveRequest instance")
profile_name = ParameterValidator.validate_profile_name(request.profile_name)
if not isinstance(request.profile_text, str) or not request.profile_text.strip():
raise InvalidParameterError("profile_text", "Value must not be empty")
if not isinstance(request.waveform_text, str):
raise InvalidParameterError("waveform_text", "Value must be a string")
try:
profile_bytes = request.profile_text.encode("ascii")
waveform_bytes = request.waveform_text.encode("ascii")
except UnicodeEncodeError as exc:
raise InvalidParameterError(
"profile_text",
"Profile payload must contain ASCII text only",
) from exc
begin_sent = False
try:
self._send_and_expect_ok(
Protocol.encode_profile_save_begin(
profile_name=profile_name,
profile_text_bytes=len(profile_bytes),
waveform_text_bytes=len(waveform_bytes),
)
)
begin_sent = True
for start in range(0, len(profile_bytes), PROFILE_SAVE_DATA_CHUNK_BYTES):
self._send_and_expect_ok(
Protocol.encode_profile_save_data(
section_id=PROFILE_SAVE_SECTION_PROFILE_TEXT,
chunk=profile_bytes[start:start + PROFILE_SAVE_DATA_CHUNK_BYTES],
)
)
for start in range(0, len(waveform_bytes), PROFILE_SAVE_DATA_CHUNK_BYTES):
self._send_and_expect_ok(
Protocol.encode_profile_save_data(
section_id=PROFILE_SAVE_SECTION_WAVEFORM_TEXT,
chunk=waveform_bytes[start:start + PROFILE_SAVE_DATA_CHUNK_BYTES],
)
)
self._send_and_expect_ok(Protocol.encode_profile_save_commit())
except Exception:
if begin_sent:
try:
self._send_and_expect_ok(Protocol.encode_profile_save_cancel())
except Exception as cancel_exc: # noqa: BLE001
logger.warning("Profile save cancel failed: %s", cancel_exc)
raise
logger.info(
"Profile saved to SD: name=%s waveform_bytes=%d",
profile_name,
len(waveform_bytes),
)
def upload_ad9102_waveform(self, samples: Sequence[int]) -> None:
"""Upload and commit a custom AD9102 waveform from signed 14-bit samples."""
if not samples:
raise InvalidParameterError("samples", "At least two samples are required")
sample_list = [self._validate_wave_sample(sample, index) for index, sample in enumerate(samples)]
sample_count = len(sample_list)
if not AD9102_SRAM_SAMPLE_MIN <= sample_count <= AD9102_SRAM_SAMPLE_MAX:
raise InvalidParameterError(
"samples",
f"Sample count must be in range [{AD9102_SRAM_SAMPLE_MIN}, {AD9102_SRAM_SAMPLE_MAX}]",
)
self._send_and_expect_ok(Protocol.encode_ad9102_wave_begin(sample_count))
for start in range(0, sample_count, AD9102_WAVE_MAX_CHUNK_SAMPLES):
chunk = sample_list[start:start + AD9102_WAVE_MAX_CHUNK_SAMPLES]
self._send_and_expect_ok(Protocol.encode_ad9102_wave_data(chunk))
self._send_and_expect_ok(Protocol.encode_ad9102_wave_commit())
logger.info("Uploaded AD9102 waveform with %d samples", sample_count)
def cancel_ad9102_waveform_upload(self) -> None:
"""Cancel an in-progress AD9102 custom waveform upload."""
self._send_and_expect_ok(Protocol.encode_ad9102_wave_cancel())
logger.info("Cancelled AD9102 waveform upload")
def get_measurements(self) -> Measurements | None:
"""Request one telemetry frame from the device."""
self._send(Protocol.encode_trans_enable())
raw = self._transport.read(GET_DATA_TOTAL_LENGTH)
if len(raw) != GET_DATA_TOTAL_LENGTH:
logger.warning("Expected %d telemetry bytes, got %d", GET_DATA_TOTAL_LENGTH, len(raw))
return None
measurements = Protocol.decode_response(raw)
self._last_measurements = measurements
if self._on_data is not None:
self._on_data(measurements)
return measurements
def get_status(self) -> DeviceStatus:
"""Query the current two-byte firmware status word."""
self._send(Protocol.encode_state())
raw = self._transport.read(STATUS_RESPONSE_LENGTH)
if len(raw) != STATUS_RESPONSE_LENGTH:
raise DeviceNotRespondingError()
state, detail = Protocol.decode_status(raw)
return DeviceStatus(
state=state,
detail=detail,
measurements=self._last_measurements,
is_connected=self.is_connected,
last_command_id=self._message_id,
error_message=Protocol.state_to_description(state),
)
def _send(self, data: bytes) -> None:
if not self.is_connected:
raise CommunicationError("Not connected to device. Call connect() first.")
self._transport.send(data)
time.sleep(WAIT_AFTER_SEND_SEC)
def _send_and_expect_ok(self, data: bytes) -> int:
self._send(data)
raw = self._transport.read(STATUS_RESPONSE_LENGTH)
if len(raw) != STATUS_RESPONSE_LENGTH:
raise DeviceNotRespondingError()
state, detail = Protocol.decode_status(raw)
if state != DeviceState.OK:
combined_code = int(state) | (detail << 8)
raise DeviceStateError(
combined_code,
Protocol.state_to_description(state),
)
return detail
@staticmethod
def _validate_int_range(value: int, name: str, minimum: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise InvalidParameterError(name, "Value must be an integer")
if not minimum <= value <= maximum:
raise InvalidParameterError(name, f"Value must be in range [{minimum}, {maximum}]")
return value
@staticmethod
def _validate_wave_sample(value: int, index: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise InvalidParameterError(f"samples[{index}]", "Value must be an integer")
if not AD9102_WAVE_SAMPLE_MIN <= value <= AD9102_WAVE_SAMPLE_MAX:
raise InvalidParameterError(
f"samples[{index}]",
f"Value must be in range [{AD9102_WAVE_SAMPLE_MIN}, {AD9102_WAVE_SAMPLE_MAX}]",
)
return value
def __enter__(self) -> "LaserController":
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
if self.is_connected:
self.disconnect()
return False