From 25545f046a6ebddedfba685f7d919ebb8e4e170d Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 21 Oct 2026 22:01:57 +0100 Subject: [PATCH] v1 `CLARIOstar`: filter detection, filters from the slides, and the z-profile of a well - `Fluorescence.detect_all_filters` scans the 11 filter positions and returns the three slides. - A `FluorescenceChannel` filter can be given by slot or name on the slides. - `Fluorescence.measure_z_profile` runs the reader's z-scan of one well, with the gain fit at the peak for a target. - The filter scan and the z-scan drain the reply in flight before STOP when cancelled. Co-Authored-By: Claude Opus 5.5 --- pylabrobot/bmg_labtech/clariostar/__init__.py | 1 + .../bmg_labtech/clariostar/driver/__init__.py | 1 + .../clariostar/driver/features/__init__.py | 1 + .../driver/features/fluorescence.py | 667 ++++++++++++++++-- .../driver/features/fluorescence_tests.py | 443 +++++++++++- .../clariostar/driver/features/measurement.py | 56 +- .../filter_detection_captures.json.gz | Bin 0 -> 64726 bytes .../test_data/z_profile_captures.json.gz | Bin 0 -> 2849 bytes .../bmg_labtech/clariostar/driver/master.py | 10 +- pylabrobot/lib/fluorescence/channels.py | 21 +- pylabrobot/lib/optics/holders.py | 8 +- 11 files changed, 1104 insertions(+), 104 deletions(-) create mode 100644 pylabrobot/bmg_labtech/clariostar/driver/features/test_data/filter_detection_captures.json.gz create mode 100644 pylabrobot/bmg_labtech/clariostar/driver/features/test_data/z_profile_captures.json.gz diff --git a/pylabrobot/bmg_labtech/clariostar/__init__.py b/pylabrobot/bmg_labtech/clariostar/__init__.py index 903c76d6d91..7a4b14aeda5 100644 --- a/pylabrobot/bmg_labtech/clariostar/__init__.py +++ b/pylabrobot/bmg_labtech/clariostar/__init__.py @@ -13,6 +13,7 @@ CLARIOstarFluorescenceSpectrumResult, CLARIOstarSimulationDriver, CLARIOstarStatus, + CLARIOstarZProfileResult, CommandRefusedError, DeviceConfiguration, Drawer, diff --git a/pylabrobot/bmg_labtech/clariostar/driver/__init__.py b/pylabrobot/bmg_labtech/clariostar/driver/__init__.py index af46dab35b2..d366a713c83 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/__init__.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/__init__.py @@ -8,6 +8,7 @@ CLARIOstarAbsorbanceSpectrumResult, CLARIOstarFluorescenceResult, CLARIOstarFluorescenceSpectrumResult, + CLARIOstarZProfileResult, Drawer, Fluorescence, FluorescenceConfiguration, diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py b/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py index a1017936edf..6bd7b6b61a4 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py @@ -8,6 +8,7 @@ from .fluorescence import ( CLARIOstarFluorescenceResult, CLARIOstarFluorescenceSpectrumResult, + CLARIOstarZProfileResult, Fluorescence, FluorescenceConfiguration, ) diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence.py b/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence.py index 7af2c42cb13..511937787d0 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence.py @@ -1,12 +1,16 @@ -"""CLARIOstar fluorescence: plate reads and spectra. +"""CLARIOstar fluorescence: plate reads, spectra, filter detection and z-profiles. A plate read goes through one to five fluorescence channels in one pass (`FluorescenceChannel`, the vendor's chromatics), from the top or the bottom optic. Excitation and emission each select their wavelength through a monochromator or a filter. A spectrum scans the excitation or the emission monochromator. +Filter detection scans every filter position; a z-profile reads one well over its focal heights, +and with a target the reader fits the gain at its peak. """ +import asyncio import logging +import time from dataclasses import dataclass, replace from datetime import datetime from typing import ( @@ -24,6 +28,13 @@ from pylabrobot.lib.fluorescence.channels import FluorescenceChannel from pylabrobot.lib.fluorescence.results import FluorescenceResult, FluorescenceSpectrumResult from pylabrobot.lib.optics.filters import DichroicFilter, OpticalFilter +from pylabrobot.lib.optics.holders import ( + DichroicFilterSlide, + EmissionFilterSlide, + ExcitationFilterSlide, + F, + FilterSlide, +) from pylabrobot.resources import Plate, Well from ..errors import FrameError @@ -66,6 +77,39 @@ class CLARIOstarFluorescenceResult(FluorescenceResult): matrix_points: Optional[Dict[str, List[float]]] = None +@dataclass(frozen=True) +class CLARIOstarZProfileResult: + """The z-scan of one well as the firmware reports it. + + Attributes: + well: the identifier of the scanned well (``"D6"``). + gain: the detector gain the firmware fits at the peak for the target; None without a target. + z_profile: (focal height in mm, counts) from the highest to the lowest point; the counts at a + gain the firmware chooses per well: for the shape, not comparable across wells. + average_signal: the firmware's average of the z-profile's five points around + `peak_focal_height`, counts. + peak_counts: the counts at `peak_focal_height` and `gain`; None without a target. + peak_detector_percent: `peak_counts` in percent of the detector's 260,000 counts, as the + target; None without a target. + peak_focal_height: the focal height the firmware computes as the z-profile's maximum, in mm. + temperature: temperature in degrees C; None: no z-scan with the temperature controller on is + captured, so the reply's temperature, if it holds one, is not decoded. + start_timestamp: when the z-scan started. + end_timestamp: when its result was in. + """ + + well: str + gain: Optional[int] + z_profile: List[Tuple[float, int]] + average_signal: int + peak_counts: Optional[int] + peak_detector_percent: Optional[float] + peak_focal_height: float + temperature: Optional[float] + start_timestamp: datetime + end_timestamp: datetime + + @dataclass class CLARIOstarFluorescenceSpectrumResult(FluorescenceSpectrumResult): """A CLARIOstar fluorescence spectrum, with the detector gain it was read with. @@ -194,10 +238,14 @@ def __init__( self._default_excitation_bandwidth = 15 # nm, the vendor's default self._default_emission_bandwidth = 20 # nm, the vendor's default self._default_optic_position: OpticPosition = "top" # the vendor's default + # The filters each slide holds, by slot: `detect_all_filters`, or assigned; None until then. + self.excitation_filter_slide: Optional[ExcitationFilterSlide] = None + self.dichroic_filter_slide: Optional[DichroicFilterSlide] = None + self.emission_filter_slide: Optional[EmissionFilterSlide] = None self._default_focal_height_percent_of_well_depth = 25.0 # ---------------------------------------- - # Defaults + # Defaults and filters from the slides # ---------------------------------------- def _check_default_bandwidth(self, name: str, value: int) -> None: @@ -272,21 +320,84 @@ def _get_default_focal_height(self, wells: Sequence[Well]) -> float: wells, self._default_focal_height_percent_of_well_depth, self.configuration.focal_height_range ) - def _get_fluorescence_channel_with_default_bandwidths( + @staticmethod + def _get_resolved_filter(value: Union[F, int, str, None]) -> Optional[F]: + """A fluorescence channel's filter, once `_get_filled_fluorescence_channel` has resolved it. + + Raises: + TypeError: if `value` is still a slot or a name. + """ + if isinstance(value, (int, str)): + raise TypeError(f"a filter must be resolved through its filter slide, got {value!r}") + return value + + @staticmethod + def _get_filter( + filter_slide: Optional[FilterSlide[F]], value: Union[F, int, str], slide: str + ) -> F: + """The filter `value` names: itself, the slide's filter at a slot, or its filter of a name. + + An empty slot within the slide is an anonymous filter (`FilterSlide.__getitem__`). + + Args: + slide: "excitation", "dichroic" or "emission", the prefix of the field named in the errors. + + Raises: + ValueError: if a slot or a name is given without a slide, a slot is outside the slide, or + no filter of the slide has the name. + """ + if not isinstance(value, (int, str)): + return value + if filter_slide is None: + raise ValueError( + f"{slide}_filter as a slot or a name needs a {slide} filter slide (run detect_all_filters " + f"or assign {slide}_filter_slide), got {value!r}" + ) + if isinstance(value, str): + try: + return filter_slide.by_name(value) + except KeyError as error: + raise ValueError(error.args[0]) from None + if not 1 <= value <= filter_slide.maximum_slots: + raise ValueError( + f"{slide}_filter slot must be 1 to {filter_slide.maximum_slots}, got {value}" + ) + return filter_slide[value] + + def _get_filled_fluorescence_channel( self, fluorescence_channel: FluorescenceChannel ) -> FluorescenceChannel: - """A copy of `fluorescence_channel`, excitation and emission bandwidths given the default. + """A copy of `fluorescence_channel` with its filters from the slides and its bandwidths filled. - Only excitation or emission without a filter or a bandwidth gets the default. + - A filter given as a slot or a name is its slide's (`_get_filter`). + - Excitation or emission without a filter or a bandwidth has the default bandwidth. + + Raises: + ValueError: if a slot or a name has no slide, a slot is outside its slide, or no filter of + the slide has the name. """ + excitation_filter = fluorescence_channel.excitation_filter + if excitation_filter is not None: + excitation_filter = self._get_filter( + self.excitation_filter_slide, excitation_filter, "excitation" + ) + dichroic_filter = fluorescence_channel.dichroic_filter + if dichroic_filter is not None: + dichroic_filter = self._get_filter(self.dichroic_filter_slide, dichroic_filter, "dichroic") + emission_filter = fluorescence_channel.emission_filter + if emission_filter is not None: + emission_filter = self._get_filter(self.emission_filter_slide, emission_filter, "emission") excitation_bandwidth = fluorescence_channel.excitation_bandwidth - if excitation_bandwidth is None and fluorescence_channel.excitation_filter is None: + if excitation_bandwidth is None and excitation_filter is None: excitation_bandwidth = self._default_excitation_bandwidth emission_bandwidth = fluorescence_channel.emission_bandwidth - if emission_bandwidth is None and fluorescence_channel.emission_filter is None: + if emission_bandwidth is None and emission_filter is None: emission_bandwidth = self._default_emission_bandwidth return replace( fluorescence_channel, + excitation_filter=excitation_filter, + dichroic_filter=dichroic_filter, + emission_filter=emission_filter, excitation_bandwidth=excitation_bandwidth, emission_bandwidth=emission_bandwidth, ) @@ -398,7 +509,8 @@ def _check_fluorescence_channel( a wavelength and a bandwidth. Args: - fluorescence_channel: the fluorescence channel, its bandwidths filled. + fluorescence_channel: the fluorescence channel, its filters resolved and its bandwidths + filled. scanned: a spectrum's scanned excitation or emission, which has no wavelength. Returns: @@ -406,20 +518,21 @@ def _check_fluorescence_channel( refuses, an excitation or emission filter without a `dichroic_filter`, a split not in 0.1 nm steps, a dichroic slot outside `dichroic_filter_slots`, `_check_bands`'s. """ + dichroic_filter = self._get_resolved_filter(fluorescence_channel.dichroic_filter) errors = self._check_excitation_or_emission_on_reader( "excitation", fluorescence_channel.excitation_wavelength, fluorescence_channel.excitation_bandwidth, - fluorescence_channel.excitation_filter, + self._get_resolved_filter(fluorescence_channel.excitation_filter), scanned=scanned == "excitation", ) + self._check_excitation_or_emission_on_reader( "emission", fluorescence_channel.emission_wavelength, fluorescence_channel.emission_bandwidth, - fluorescence_channel.emission_filter, + self._get_resolved_filter(fluorescence_channel.emission_filter), scanned=scanned == "emission", ) - if fluorescence_channel.dichroic_filter is None and ( + if dichroic_filter is None and ( fluorescence_channel.excitation_filter is not None or fluorescence_channel.emission_filter is not None ): @@ -430,9 +543,9 @@ def _check_fluorescence_channel( not isinstance(split, (int, float)) or abs(split * 10 - round(split * 10)) > 1e-9 ): errors.append(f"dichroic_split_wavelength must be nm in 0.1 nm steps, got {split!r}") - if fluorescence_channel.dichroic_filter is not None: + if dichroic_filter is not None: slots = self.configuration.dichroic_filter_slots - slot = fluorescence_channel.dichroic_filter.slot + slot = dichroic_filter.slot if not isinstance(slot, int) or not 1 <= slot <= slots: errors.append(f"dichroic_filter slot must be 1 to {slots}, got {slot!r}") if ( @@ -448,7 +561,7 @@ def _check_fluorescence_channel( fluorescence_channel.emission_wavelength, fluorescence_channel.emission_bandwidth, split=split, - dichroic_filter=fluorescence_channel.dichroic_filter, + dichroic_filter=dichroic_filter, ) return errors @@ -507,27 +620,28 @@ def _compute_optics( ValueError: if excitation or emission has neither a filter nor a wavelength and bandwidth. """ per_slide = self.configuration.filter_slots_per_slide + excitation_filter = self._get_resolved_filter(fluorescence_channel.excitation_filter) + emission_filter = self._get_resolved_filter(fluorescence_channel.emission_filter) + dichroic_filter = self._get_resolved_filter(fluorescence_channel.dichroic_filter) excitation_wavelength = fluorescence_channel.excitation_wavelength excitation_bandwidth = fluorescence_channel.excitation_bandwidth emission_wavelength = fluorescence_channel.emission_wavelength emission_bandwidth = fluorescence_channel.emission_bandwidth - if fluorescence_channel.excitation_filter is not None: - excitation = _compute_filter_positions(fluorescence_channel.excitation_filter.slot, per_slide) + if excitation_filter is not None: + excitation = _compute_filter_positions(excitation_filter.slot, per_slide) elif excitation_wavelength is not None and excitation_bandwidth is not None: excitation = _compute_band_edges(excitation_wavelength, excitation_bandwidth) else: raise ValueError("give excitation_wavelength and excitation_bandwidth, or excitation_filter") - if fluorescence_channel.emission_filter is not None: - first, second = _compute_filter_positions( - fluorescence_channel.emission_filter.slot, per_slide - ) + if emission_filter is not None: + first, second = _compute_filter_positions(emission_filter.slot, per_slide) emission = (second, first) elif emission_wavelength is not None and emission_bandwidth is not None: emission = _compute_band_edges(emission_wavelength, emission_bandwidth) else: raise ValueError("give emission_wavelength and emission_bandwidth, or emission_filter") - if fluorescence_channel.dichroic_filter is not None: - dichroic = fluorescence_channel.dichroic_filter.slot + 1 + if dichroic_filter is not None: + dichroic = dichroic_filter.slot + 1 elif fluorescence_channel.dichroic_split_wavelength is not None: dichroic = round(fluorescence_channel.dichroic_split_wavelength * 10) else: @@ -631,11 +745,11 @@ def _compute_run_parameters( # -- the log and the results --------------------------------------------------------------------- def _describe_fluorescence_channel( - self, fluorescence_channel: FluorescenceChannel, gain: int + self, fluorescence_channel: FluorescenceChannel, gain: Optional[int] = None ) -> str: - """One fluorescence channel of the read, for the log: excitation, emission and gain. + """One fluorescence channel of the read, for the log: excitation, emission and the gain. - Excitation and emission each as wavelength and bandwidth, or filter. + Excitation and emission each as wavelength and bandwidth, or filter; the gain only when given. """ def describe( @@ -652,14 +766,14 @@ def describe( excitation = describe( fluorescence_channel.excitation_wavelength, fluorescence_channel.excitation_bandwidth, - fluorescence_channel.excitation_filter, + self._get_resolved_filter(fluorescence_channel.excitation_filter), ) emission = describe( fluorescence_channel.emission_wavelength, fluorescence_channel.emission_bandwidth, - fluorescence_channel.emission_filter, + self._get_resolved_filter(fluorescence_channel.emission_filter), ) - return f"{excitation} -> {emission}, gain {gain}" + return f"{excitation} -> {emission}" + ("" if gain is None else f", gain {gain}") def _get_result_optics( self, fluorescence_channel: FluorescenceChannel @@ -669,8 +783,8 @@ def _get_result_optics( Excitation's and emission's filter passband, or monochromator wavelength and the bandwidth the read used. """ - excitation_filter = fluorescence_channel.excitation_filter - emission_filter = fluorescence_channel.emission_filter + excitation_filter = self._get_resolved_filter(fluorescence_channel.excitation_filter) + emission_filter = self._get_resolved_filter(fluorescence_channel.emission_filter) optics: Dict[str, Optional[float]] = {} if excitation_filter is not None: optics["excitation_wavelength"] = excitation_filter.center_wavelength @@ -686,6 +800,476 @@ def _get_result_optics( optics["emission_bandwidth"] = fluorescence_channel.emission_bandwidth return optics + # ---------------------------------------- + # Filter detection + # ---------------------------------------- + + # Filter detection `24`, mode byte: the slide's code OR bit 5, which the vendor always sets. + _FILTER_SCAN_SLIDES: Dict[str, int] = {"excitation": 0x00, "emission": 0x01, "dichroic": 0x03} + _FILTER_SCAN_BIT_5 = 0x20 + # The emission detector gains an emission scan sends before the slide positions, as the vendor + # sends them; excitation and dichroic scans send zeros. + _EMISSION_SCAN_GAINS = (1100, 1200, 1100, 1150) + # Seconds one position may take: excitation and dichroic 6.4 s at most, emission 48.6 s. + _FILTER_SCAN_TIMEOUT = 20.0 + _EMISSION_FILTER_SCAN_TIMEOUT = 100.0 + # FILTER_RESULT byte 6: the slide's code OR 0x80, which brings the slopes and the curve's range. + _FILTER_RESULT_LAYOUT = 0x80 + + # -- `24` parameters ----------------------------------------------------------------------------- + + def _compute_filter_scan_parameters(self, slide: str, slot: int) -> bytes: + """`24`'s parameters for one filter position: the mode, then 13 words u16 BE. + + - The first four: `_EMISSION_SCAN_GAINS` for emission, zeros otherwise. + - Then the slide positions: two slits, EX_SP, EX_LP, BSP, EM_SP, EM_LP, EM_SLIT, EX_SLIT + (the reader's slides), 1 their home. + - A filter's positions are the RUN's (`_compute_filter_positions`): excitation (EX_SP, EX_LP), + dichroic BSP = slot + 1, emission (EM_LP, EM_SP). + """ + per_slide = self.configuration.filter_slots_per_slide + excitation, dichroic, emission = (1, 1), 1, (1, 1) + if slide == "excitation": + excitation = _compute_filter_positions(slot, per_slide) + elif slide == "dichroic": + dichroic = slot + 1 + else: + long_pass, short_pass = _compute_filter_positions(slot, per_slide) + emission = (short_pass, long_pass) + gains = self._EMISSION_SCAN_GAINS if slide == "emission" else (0, 0, 0, 0) + words = (*gains, 1, 1, *excitation, dichroic, *emission, 1, 1) + mode = self._FILTER_SCAN_SLIDES[slide] | self._FILTER_SCAN_BIT_5 + return bytes([mode]) + b"".join(word.to_bytes(2, "big") for word in words) + + # -- decoding ------------------------------------------------------------------------------------ + + def _decode_filter_result(self, reply: bytes, slide: str) -> Tuple[int, int, int, int, int]: + """A FILTER_RESULT (`05 1b`) reply's bandwidth, two edges and two slopes. + + - The edges are the band's thresholds; bandwidth and edges in 0.1 nm. + - Bytes 7-14: peak, bandwidth, edge 1, edge 2 (u16 BE). + - Bytes 15-18: the slopes (s16 BE); 19-22: the curve's range. + - Bytes 23-24: the point count n, then n points of 8 bytes and 8 bytes `00`. + + Raises: + ValueError: if the reply is not a whole FILTER_RESULT of `slide`. + """ + layout = self._FILTER_SCAN_SLIDES[slide] | self._FILTER_RESULT_LAYOUT + if len(reply) < 25 or reply[6] != layout: + raise ValueError(f"not a filter result of the {slide} slide, got {reply[:7].hex()}") + points = int.from_bytes(reply[23:25], "big") + if len(reply) < 25 + 8 * points + 8: + raise ValueError(f"filter result cut short: {points} points, got {len(reply)} bytes") + bandwidth, first_edge, second_edge = ( + int.from_bytes(reply[offset : offset + 2], "big") for offset in (9, 11, 13) + ) + first_slope, second_slope = ( + int.from_bytes(reply[offset : offset + 2], "big", signed=True) for offset in (15, 17) + ) + return bandwidth, first_edge, second_edge, first_slope, second_slope + + @staticmethod + def _compute_optical_filter( + slot: int, result: Tuple[int, int, int, int, int] + ) -> Optional[OpticalFilter]: + """The excitation or emission filter a FILTER_RESULT describes; None for an empty position. + + As the vendor decides: + - a bandwidth: a band-pass between the edges; + - else a first edge with a slope: a long-pass, open above; + - else a second edge with a slope: a short-pass, its band not stated (not captured). + """ + bandwidth, first_edge, second_edge, first_slope, second_slope = result + if bandwidth > 0: + return OpticalFilter(slot, passbands=((first_edge / 10, second_edge / 10),)) + if first_edge > 0 and first_slope != 0: + return OpticalFilter(slot, passbands=((first_edge / 10, None),)) + if second_edge > 0 and second_slope != 0: + # TODO(device): no short-pass filter is captured; state its band once a reader reports one. + logger.warning( + "filter %d is a short-pass up to %s nm; its band is not stated", slot, second_edge / 10 + ) + return OpticalFilter(slot) + return None + + @staticmethod + def _compute_dichroic_filter( + slot: int, result: Tuple[int, int, int, int, int] + ) -> Optional[DichroicFilter]: + """The dichroic a FILTER_RESULT describes; None for an empty position. + + As the vendor decides: + - a first edge: a long-pass that transmits from it, open above; + - else a second edge: a short-pass, its bands not stated (not captured). + """ + _, first_edge, second_edge, _, _ = result + if first_edge > 0: + return DichroicFilter(slot, transmission_bands=((first_edge / 10, None),)) + if second_edge > 0: + # TODO(device): no short-pass dichroic is captured; state its bands once a reader reports one. + logger.warning( + "dichroic %d is a short-pass at %s nm; its bands are not stated", slot, second_edge / 10 + ) + return DichroicFilter(slot) + return None + + # -- detecting ----------------------------------------------------------------------------------- + + async def _detect_filter(self, slide: str, slot: int) -> bytes: + """Scan one filter position (`24`) and return its FILTER_RESULT reply. + + As the vendor: status until the data is ready (the reader may still be busy), SPECTRAL_DATA + (`05 11`, not decoded) then FILTER_RESULT (`05 1b`), then status until idle. Any failure, a + timeout or a cancelled call stops the scan (`0b 00`) first. + + Args: + slide: "excitation", "dichroic" or "emission". + slot: the filter position on that slide, from 1. + + Raises: + TimeoutError: if the position takes longer than its timeout (`_FILTER_SCAN_TIMEOUT`, + `_EMISSION_FILTER_SCAN_TIMEOUT` for emission). + RuntimeError: if the scan ends without reporting its data ready. + """ + timeout = ( + self._EMISSION_FILTER_SCAN_TIMEOUT if slide == "emission" else self._FILTER_SCAN_TIMEOUT + ) + parameters = self._compute_filter_scan_parameters(slide, slot) + driver = self._driver + await driver.send_command(driver.CommandFamily.FILTER_SCAN, parameters=parameters) + start = time.monotonic() + try: + await asyncio.sleep(driver.default_poll_interval) + while True: + status = await driver.send_command(driver.CommandFamily.STATUS, retries=5) + if len(status) > 2 and status[2] & self._DATA_READY: + break + if not driver._parse_status(status).busy: + raise RuntimeError(f"the {slide} filter scan ended without reporting its data ready") + if time.monotonic() - start >= timeout: + raise TimeoutError(f"the {slide} filter scan took longer than {timeout} s") + await asyncio.sleep(driver.default_poll_interval) + await driver.send_command( + driver.CommandFamily.REQUEST, + driver.Command.SPECTRAL_DATA, + parameters=bytes(5), + when_idle=False, + ) + reply = await driver.send_command( + driver.CommandFamily.REQUEST, + driver.Command.FILTER_RESULT, + parameters=bytes(5), + when_idle=False, + ) + await driver._wait_until_ready( + timeout=max(timeout - (time.monotonic() - start), 0), + poll_interval=driver.default_poll_interval, + ) + return reply + except BaseException: + await self._drain_and_stop(f"{slide} filter scan") + raise + + async def detect_all_filters( + self, + ) -> Tuple[ExcitationFilterSlide, DichroicFilterSlide, EmissionFilterSlide]: + """Detect the filters in every position, as the vendor's "Detect all filters". + + - One scan each: excitation 1 to 4, dichroic A to C, emission 5 to 8 (slots 1 to 4). + - Excitation and dichroic positions take about 8 s each, emission about 49 s: about 4 minutes. + - Detected filters have no name, their edges in nm as the reader states them; an empty + position holds none. + - The slides replace `excitation_filter_slide`, `dichroic_filter_slide` and + `emission_filter_slide` once every position is scanned. + + Returns: + the excitation, dichroic and emission filter slides. + + Raises: + RuntimeError: if the reader reports no spectrometer (absorbance), or no extended-red + photomultiplier for the emission scans, whose captured limits are its; if a scan ends + without its data. + TimeoutError: if a position takes longer than its timeout (`_FILTER_SCAN_TIMEOUT`, + `_EMISSION_FILTER_SCAN_TIMEOUT` for emission). + ValueError: if a FILTER_RESULT is not one of its position. + """ + device = self._driver.configuration + if device.spectrometer is False or device.absorbance_available is False: + raise RuntimeError("the reader reports no spectrometer, which filter detection scans with") + if device.extended_red_photomultiplier_tube is False: + raise RuntimeError( + "the reader reports no extended-red photomultiplier; the emission scans' limits are only " + "captured with one" + ) + excitation_slide = ExcitationFilterSlide(self.configuration.excitation_filter_slots) + dichroic_slide = DichroicFilterSlide(self.configuration.dichroic_filter_slots) + emission_slide = EmissionFilterSlide(self.configuration.emission_filter_slots) + for slot in range(1, excitation_slide.maximum_slots + 1): + reply = await self._detect_filter("excitation", slot) + optical_filter = self._compute_optical_filter( + slot, self._decode_filter_result(reply, "excitation") + ) + if optical_filter is not None: + excitation_slide.register(optical_filter) + for slot in range(1, dichroic_slide.maximum_slots + 1): + reply = await self._detect_filter("dichroic", slot) + dichroic_filter = self._compute_dichroic_filter( + slot, self._decode_filter_result(reply, "dichroic") + ) + if dichroic_filter is not None: + dichroic_slide.register(dichroic_filter) + for slot in range(1, emission_slide.maximum_slots + 1): + reply = await self._detect_filter("emission", slot) + optical_filter = self._compute_optical_filter( + slot, self._decode_filter_result(reply, "emission") + ) + if optical_filter is not None: + emission_slide.register(optical_filter) + logger.info("filter detection: %r, %r, %r", excitation_slide, dichroic_slide, emission_slide) + self.excitation_filter_slide = excitation_slide + self.dichroic_filter_slide = dichroic_slide + self.emission_filter_slide = emission_slide + return excitation_slide, dichroic_slide, emission_slide + + # ---------------------------------------- + # Z-profiles + # ---------------------------------------- + + # The gain definition bytes of `09`, the z-scan, as the vendor sends them. + _Z_PROFILE_GAIN_DEFINITION = bytes([0x04, 0x08]) + # The step between focal heights, in 0.01 mm: the only one captured. + _Z_PROFILE_RESOLUTION = 10 + # Before the optics, as the end of the RUN's chromatic header. + _Z_PROFILE_OPTICS_HEADER = bytes.fromhex("0000000c") + # Status reply byte 2 bit 5: the z-profile is ready. + _Z_PROFILE_RESULT_READY = 0x20 + + # -- checks -------------------------------------------------------------------------------------- + + def _check_z_profile_arguments( + self, + fluorescence_channel: FluorescenceChannel, + maximum_focal_height: float, + gain_target_percent: Optional[float], + *, + timeout: Optional[float] = None, + ) -> None: + """Raise if the fluorescence channel, maximum focal height, target or timeout is out of range. + + Raises: + ValueError: listing every violation: the fluorescence channel's + (`_check_fluorescence_channel`), a maximum focal height not in 0.1 mm steps above 0 within + `focal_height_range` (the reader refuses 0), a target not above 0 up to 100 %, a timeout + not positive. + """ + errors = self._check_fluorescence_channel(fluorescence_channel) + lowest, highest = self.configuration.focal_height_range + if ( + not lowest < maximum_focal_height <= highest + or not maximum_focal_height > 0 + or abs(maximum_focal_height * 10 - round(maximum_focal_height * 10)) > 1e-9 + ): + errors.append( + f"maximum_focal_height must be above {max(lowest, 0)} up to {highest} mm in 0.1 mm " + f"steps, got {maximum_focal_height}" + ) + if gain_target_percent is not None and not 0 < gain_target_percent <= 100: + errors.append(f"gain_target_percent must be above 0 up to 100, got {gain_target_percent}") + if timeout is not None and not timeout > 0: + errors.append(f"timeout must be positive, got {timeout}") + if errors: + raise ValueError("; ".join(errors)) + + # -- `09` parameters ----------------------------------------------------------------------------- + + def _compute_z_profile_parameters( + self, + plate: Plate, + well: Well, + fluorescence_channel: FluorescenceChannel, + maximum_focal_height: float, + gain_target_percent: Optional[float], + ) -> bytes: + """`09`'s 42 parameter bytes. + + The plate's geometry, the well's column and row (from 1), the gain definition, the resolution, + `maximum_focal_height`, the target in 0.01 % (0 without), then the optics: + `_Z_PROFILE_OPTICS_HEADER` and a RUN fluorescence channel's bytes without its gain and + closing `00` (`_encode_optics`). + """ + index = next(i for i, item in enumerate(plate.get_all_items()) if item is well) + rows = plate.num_items_y + target = 0 if gain_target_percent is None else round(gain_target_percent * 100) + optics = self._encode_optics( + *self._compute_optics(fluorescence_channel), + *self._compute_wavelength_selectors(fluorescence_channel), + ) + return ( + self._encode_plate_geometry(plate) + + bytes([index // rows + 1, index % rows + 1]) + + self._Z_PROFILE_GAIN_DEFINITION + + self._Z_PROFILE_RESOLUTION.to_bytes(2, "big") + + round(maximum_focal_height * 100).to_bytes(2, "big") + + target.to_bytes(2, "big") + + self._Z_PROFILE_OPTICS_HEADER + + optics + ) + + # -- decoding ------------------------------------------------------------------------------------ + + @staticmethod + def _decode_z_profile_result( + reply: bytes, plate: Plate, *, start_timestamp: datetime, end_timestamp: datetime + ) -> CLARIOstarZProfileResult: + """The z-profile of a FOCUS_RESULT (`05 05`) reply. + + - Bytes 7-8: the gain; 9-12 the peak counts (u32 BE); 13-14 their percent of the detector's + range in 0.01 %. + - Bytes 15 and 16: the well's column and row (from 1). + - Bytes 17-18: the peak focal height in 0.01 mm (s16 BE); 19-22 the z-profile's average + around it. + - Bytes 23-24: the point count n, then n points (s32 BE height in 0.01 mm, which can be below + 0; u32 BE counts). + - Gain, peak counts and percent are 0 without a target. + + Raises: + ValueError: if the reply is cut short or names a well not on the plate. + """ + if len(reply) < 25: + raise ValueError(f"FOCUS_RESULT reply too short, got {len(reply)} bytes") + points = int.from_bytes(reply[23:25], "big") + if len(reply) < 25 + 8 * points: + raise ValueError(f"FOCUS_RESULT reply cut short: {points} points, got {len(reply)} bytes") + column, row = reply[15], reply[16] + if not (1 <= column <= plate.num_items_x and 1 <= row <= plate.num_items_y): + raise ValueError( + f"FOCUS_RESULT reply names a well not on the plate, got column {column}, row {row}" + ) + well = plate.get_all_items()[(column - 1) * plate.num_items_y + row - 1] + gain = int.from_bytes(reply[7:9], "big") + peak_counts = int.from_bytes(reply[9:13], "big") + percent = int.from_bytes(reply[13:15], "big") + z_profile = [ + ( + int.from_bytes(reply[offset : offset + 4], "big", signed=True) / 100, + int.from_bytes(reply[offset + 4 : offset + 8], "big"), + ) + for offset in range(25, 25 + 8 * points, 8) + ] + return CLARIOstarZProfileResult( + peak_focal_height=int.from_bytes(reply[17:19], "big", signed=True) / 100, + gain=gain or None, + well=plate.get_child_identifier(well), + peak_counts=peak_counts or None, + peak_detector_percent=percent / 100 if percent else None, + average_signal=int.from_bytes(reply[19:23], "big"), + z_profile=z_profile, + # TODO(device): no z-scan with the temperature controller on is captured. + temperature=None, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + ) + + # -- measuring ----------------------------------------------------------------------------------- + + async def _run_z_profile(self, parameters: bytes, timeout: float) -> bytes: + """Send a z-scan (`09`) and return its FOCUS_RESULT reply. + + As `_run_measurement`: status until the reader is idle, its stage at status byte 7 and the + gain it tries at 9-10; the result must be ready (`_Z_PROFILE_RESULT_READY`), then + `05 05 00 00 00 00 00`. Any failure, a timeout or a cancelled call stops it (`0b 00`) first. + + Raises: + TimeoutError: if the z-scan takes longer than `timeout` seconds. + RuntimeError: if it ends without reporting its result ready. + """ + driver = self._driver + await driver.send_command(driver.CommandFamily.FOCUS_WELL, parameters=parameters) + start = time.monotonic() + stage: Optional[int] = None + try: + await asyncio.sleep(driver.default_poll_interval) + while True: + status = await driver.send_command(driver.CommandFamily.STATUS, retries=5) + if not driver._parse_status(status).busy: + break + if len(status) > 10 and status[7] != stage: + stage = status[7] + logger.debug("z-scan: stage %d, gain %d", stage, int.from_bytes(status[9:11], "big")) + if time.monotonic() - start >= timeout: + raise TimeoutError(f"the z-scan took longer than {timeout} s") + await asyncio.sleep(driver.default_poll_interval) + if not (len(status) > 2 and status[2] & self._Z_PROFILE_RESULT_READY): + raise RuntimeError("the z-scan ended without reporting its result ready") + return await driver.send_command( + driver.CommandFamily.REQUEST, + driver.Command.FOCUS_RESULT, + parameters=bytes(5), + when_idle=False, + ) + except BaseException: + await self._drain_and_stop("z-scan") + raise + + async def measure_z_profile( + self, + fluorescence_channel: FluorescenceChannel, + *, + well: Well, + gain_target_percent: Optional[float] = None, + maximum_focal_height: float = 15.0, + timeout: Optional[float] = None, + ) -> CLARIOstarZProfileResult: + """Measure the z-profile of `well` from the top and, with a target, fit the gain at its peak. + + - The reader reads the well from `maximum_focal_height` down in 0.1 mm steps to a lowest + height it chooses (0.7 mm and -0.6 mm on hardware), and computes the peak (about 8 s). + - With `gain_target_percent` it then fits the gain at the peak until the well reads that + percent of the detector's 260,000 counts (about 14 s in all). + - For the brightest of several wells, `measure` them first and pass the brightest. + - Cancelling the call stops it on the reader. + + Args: + fluorescence_channel: the optics, as for `measure` (`_check_fluorescence_channel`); a + bandwidth None is the default bandwidth, a filter given as a slot or a name the slides'. + well: the well to scan, on the plate in the plate holder. + gain_target_percent: above 0 up to 100; None measures the z-profile only. + maximum_focal_height: the height the scan starts at, mm above 0 in 0.1 mm steps within + `configuration.focal_height_range`. + timeout: seconds the z-scan may take; `default_read_timeout` when None. + + Returns: + the z-profile, its peak, and the gain with a target. + + Raises: + RuntimeError: if no plate is in the plate holder, or the z-scan ends without a result. + ValueError: if an argument is out of range (`_check_z_profile_arguments`, `_get_filter`), + the well is not on the plate, or the reply is not a z-profile. + TimeoutError: if the z-scan takes longer than `timeout` seconds. + """ + filled = self._get_filled_fluorescence_channel(fluorescence_channel) + self._check_z_profile_arguments( + filled, maximum_focal_height, gain_target_percent, timeout=timeout + ) + plate, _ = self._get_plate_and_wells(MeasurementSettings(), None, [well]) + parameters = self._compute_z_profile_parameters( + plate, well, filled, maximum_focal_height, gain_target_percent + ) + logger.info( + "z-profile: %s on %s of %s, gain target %s %%", + self._describe_fluorescence_channel(filled), + plate.get_child_identifier(well), + plate.name, + gain_target_percent, + ) + start_timestamp = datetime.now() + reply = await self._run_z_profile( + parameters, self.default_read_timeout if timeout is None else timeout + ) + return self._decode_z_profile_result( + reply, plate, start_timestamp=start_timestamp, end_timestamp=datetime.now() + ) + # ---------------------------------------- # Discrete reads # ---------------------------------------- @@ -851,8 +1435,7 @@ def _get_fluorescence_channels_and_gains( ) -> Tuple[List[FluorescenceChannel], List[int]]: """`measure`'s fluorescence channels, filled, and a gain for each. - Each gets the default bandwidths (`_get_fluorescence_channel_with_default_bandwidths`); one gain - is given for all, or one each. + Each is filled by `_get_filled_fluorescence_channel`; one gain is given for all, or one each. """ given = ( [fluorescence_channels] @@ -860,8 +1443,7 @@ def _get_fluorescence_channels_and_gains( else list(fluorescence_channels) ) filled = [ - self._get_fluorescence_channel_with_default_bandwidths(fluorescence_channel) - for fluorescence_channel in given + self._get_filled_fluorescence_channel(fluorescence_channel) for fluorescence_channel in given ] gains = list(gain) if isinstance(gain, Sequence) else [gain] * len(filled) return filled, gains @@ -920,14 +1502,16 @@ async def measure( Args: fluorescence_channels: one `FluorescenceChannel` (`pylabrobot.lib.fluorescence`), or 1 to `configuration.maximum_chromatics` of them, each within `_check_fluorescence_channel`; a - bandwidth None is the default bandwidth. + bandwidth None is the default bandwidth, a filter given as a slot or a name the slides' + (`detect_all_filters`). gain: the detector gain within `configuration.gain_range`: one for every fluorescence channel, or one per fluorescence channel in the order given. wells: the wells to measure, on the plate; None measures every well. A matrix scan reads one well (no fluorescence matrix of several wells is captured). focal_height: mm above the plate's base, within `configuration.focal_height_range`, in 0.1 mm steps. None reads at `default_focal_height_percent_of_well_depth` (25: a quarter - up the well, for low and moderate fills). + up the well, for low and moderate fills). For full wells, or the most signal, focus on a + representative well with `measure_z_profile` and pass its `peak_focal_height`. optic_position: read from the top, or from the bottom if the reader has one; None for `default_optic_position`. flying_mode: read each well without stopping: point reads of 1 or 3 flashes with no @@ -945,9 +1529,9 @@ async def measure( Raises: RuntimeError: if no plate is in the plate holder, or the run ends without its data. - ValueError: if an argument is out of range (`_check_arguments`), a matrix scan has several - wells, the settings cannot read the wells of the plate, or the reply is not this read's - (`_decode_data_reply`). + ValueError: if an argument is out of range (`_check_arguments`), a filter's slot or name has + no slide or is not on it (`_get_filter`), a matrix scan has several wells, the settings + cannot read the wells of the plate, or the reply is not this read's (`_decode_data_reply`). TimeoutError: if the run takes longer than `timeout` seconds. """ measurement = MeasurementSettings() if measurement_settings is None else measurement_settings @@ -1225,7 +1809,8 @@ async def measure_spectrum( step_size: nm between wavelengths: 1, the only one captured. focal_height: mm above the plate's base, within `configuration.focal_height_range`, in 0.1 mm steps. None reads at `default_focal_height_percent_of_well_depth` (25: a quarter - up the well, for low and moderate fills). + up the well, for low and moderate fills). For full wells, or the most signal, focus on a + representative well with `measure_z_profile` and pass its `peak_focal_height`. measurement_settings: how to read, without a pause, a matrix or another corner, reading direction or bidirectional setting; None reads with the defaults. shake_settings: shake the plate before the read; None does not shake. @@ -1244,7 +1829,7 @@ async def measure_spectrum( FrameError: if a data page arrives short on the second read too. """ measurement = MeasurementSettings() if measurement_settings is None else measurement_settings - fixed = self._get_fluorescence_channel_with_default_bandwidths( + fixed = self._get_filled_fluorescence_channel( FluorescenceChannel( excitation_wavelength=excitation_wavelength, excitation_bandwidth=excitation_bandwidth, diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence_tests.py b/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence_tests.py index 6ae17d750f5..cffa814dd90 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence_tests.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/fluorescence_tests.py @@ -10,10 +10,12 @@ import os import unittest from dataclasses import replace +from datetime import datetime from typing import Any, Dict, List, Optional, Sequence, Tuple, Union from unittest.mock import patch from pylabrobot.bmg_labtech.clariostar.driver.configuration_tests import decoded +from pylabrobot.bmg_labtech.clariostar.driver.features import fluorescence from pylabrobot.bmg_labtech.clariostar.driver.features import measurement as measurement_module from pylabrobot.bmg_labtech.clariostar.driver.features.absorbance_tests import ( COLUMN_1_DATA_REPLY, @@ -44,6 +46,11 @@ from pylabrobot.lib.fluorescence.channels import FluorescenceChannel from pylabrobot.lib.fluorescence.results import FluorescenceSpectrumResult from pylabrobot.lib.optics.filters import DichroicFilter, OpticalFilter +from pylabrobot.lib.optics.holders import ( + DichroicFilterSlide, + EmissionFilterSlide, + ExcitationFilterSlide, +) from pylabrobot.resources import Plate, Well, cor_96_wellplate_360uL_Fb, create_ordered_items_2d # The vendor's default read: 485/15 to 528/20 nm, gain 1000, 8.5 mm, 10 flashes. @@ -492,47 +499,18 @@ async def test_captured_reads_are_sent_with_nominal_band_edges(self): self.assertEqual( result.excitation_bandwidth, excitation_filter.bandwidth - if excitation_filter is not None + if isinstance(excitation_filter, OpticalFilter) else fluorescence_channel.excitation_bandwidth or 15, ) self.assertEqual( result.emission_bandwidth, emission_filter.bandwidth - if emission_filter is not None + if isinstance(emission_filter, OpticalFilter) else fluorescence_channel.emission_bandwidth or 20, ) self.assertEqual(result.gain, arguments.get("gain", 1000)) -class FilterPositionTests(unittest.IsolatedAsyncioTestCase): - async def test_every_slot_moves_its_slides(self): - """Excitation (slide 1, slide 2), emission (slide 2, slide 1), dichroic slot + 1; 1 is a - slide's home, 2 its first filter (the filter detection capture). Only slot 1 and dichroic A - are a reader's (F-Lf01); the others are predictions no reader has answered.""" - excitation = {1: (2, 1), 2: (3, 1), 3: (1, 2), 4: (1, 3)} - emission = {1: (1, 2), 2: (1, 3), 3: (2, 1), 4: (3, 1)} - for slot in range(1, 5): - for dichroic in range(1, 4): - with self.subTest(slot=slot, dichroic=dichroic): - io = FakeFTDI() - await read( - io, - TEMPERATURE_37_DATA_REPLY, - FluorescenceChannel( - excitation_filter=OpticalFilter(slot), - dichroic_filter=DichroicFilter(dichroic), - emission_filter=OpticalFilter(slot), - ), - ) - payload = ftdi_extract_payload(io.written[1]) - fluorescence_channel = len(payload) - 36 - fields = [ - int.from_bytes(payload[fluorescence_channel + i : fluorescence_channel + i + 2], "big") - for i in (2, 4, 6, 8, 10) - ] - self.assertEqual(fields, [*excitation[slot], dichroic + 1, *emission[slot]]) - - class ReadTests(unittest.IsolatedAsyncioTestCase): async def test_counts_are_the_vendors_export(self): _, result = await read(FakeFTDI(), TEMPERATURE_37_DATA_REPLY) @@ -1209,5 +1187,408 @@ def test_the_wavelength_limits_follow_the_photomultipliers(self): ) +def load_filter_detection() -> List[Dict[str, Any]]: + """The reader's filter detection, its 11 positions in order. + + Each holds its `24` frame and the replies the driver reads, as hex: the status before the `24`, + the reply to it, to `80` until data ready, to `05 11`, to `05 1b`, then the next status. + """ + path = os.path.join(os.path.dirname(__file__), "test_data", "filter_detection_captures.json.gz") + with gzip.open(path, "rt") as f: + return list(json.load(f)["positions"]) + + +# The detection's positions, in its order (excitation 1-4, dichroic A-C, emission 5-8). +DETECTION_POSITIONS = [ + *(("excitation", slot) for slot in range(1, 5)), + *(("dichroic", slot) for slot in range(1, 4)), + *(("emission", slot) for slot in range(1, 5)), +] + + +def detection_result(slide: str, slot: int) -> bytes: + """The payload of the FILTER_RESULT the reader sent for a position, the reply to `05 1b`.""" + position = load_filter_detection()[DETECTION_POSITIONS.index((slide, slot))] + return ftdi_extract_payload(bytes.fromhex(position["replies"][-2])) + + +def decode_filter_result(reply: bytes, slide: str) -> Tuple[int, int, int, int, int]: + """`Fluorescence._decode_filter_result` of a feature without a plate.""" + return make_fluorescence(FakeFTDI())._decode_filter_result(reply, slide) + + +def make_detecting_fluorescence(io: FakeFTDI) -> Fluorescence: + """A fluorescence feature of the captured reader, answered by the whole detection capture.""" + io.replies = [ + bytes.fromhex(reply) for position in load_filter_detection() for reply in position["replies"] + ] + return make_fluorescence(io) + + +class FilterDetectionTests(unittest.IsolatedAsyncioTestCase): + async def test_detection_as_the_reader_answered(self): + """The reader's filters: excitation 1, dichroic A and emission 5, every other position empty.""" + io = FakeFTDI(chunk=4096) + feature = make_detecting_fluorescence(io) + feature.excitation_filter_slide = ExcitationFilterSlide(4) # a slide the detection replaces + feature.excitation_filter_slide.register(OpticalFilter(3, "old")) + excitation, dichroic, emission = await feature.detect_all_filters() + self.assertIs(feature.excitation_filter_slide, excitation) + self.assertIs(feature.dichroic_filter_slide, dichroic) + self.assertIs(feature.emission_filter_slide, emission) + self.assertEqual(excitation[1], OpticalFilter(1, passbands=((282.2, 371.8),))) + self.assertEqual(dichroic[1], DichroicFilter(1, transmission_bands=((421.8, None),))) + self.assertEqual(emission[1], OpticalFilter(1, passbands=((604.9, 623.2),))) + self.assertEqual(repr(excitation), "ExcitationFilterSlide(4 slots, {1: (unnamed)})") + self.assertEqual(repr(dichroic), "DichroicFilterSlide(3 slots, {1: (unnamed)})") + self.assertEqual(repr(emission), "EmissionFilterSlide(4 slots, {1: (unnamed)})") + # Each position: status, `24`, status until data ready, `05 11` and `05 1b` without a status + # before them (the reader still busy), status until idle. + expected: List[bytes] = [] + for position in load_filter_detection(): + polls = len(position["replies"]) - 5 + expected += [b"\x80", ftdi_extract_payload(bytes.fromhex(position["request"]))] + expected += [b"\x80"] * polls + [bytes.fromhex("05110000000000")] + expected += [bytes.fromhex("051b0000000000"), b"\x80"] + self.assertEqual([ftdi_extract_payload(frame) for frame in io.written], expected) + self.assertEqual(io.replies, []) + + def test_long_pass_and_short_pass_filters_are_decoded(self): + """No long-pass or short-pass filter is captured: the replies are real results edited.""" + # Not a reader's reply: excitation 1's without its bandwidth, then also without its first edge. + long_pass = bytearray(detection_result("excitation", 1)) + long_pass[9:11] = bytes(2) + result = decode_filter_result(bytes(long_pass), "excitation") + self.assertEqual( + Fluorescence._compute_optical_filter(1, result), OpticalFilter(1, passbands=((282.2, None),)) + ) + long_pass[11:13] = bytes(2) + result = decode_filter_result(bytes(long_pass), "excitation") + with patch.object(fluorescence.logger, "warning") as warning: + self.assertEqual(Fluorescence._compute_optical_filter(1, result), OpticalFilter(1)) + warning.assert_called_once() # a short-pass, its band not stated + # Not a reader's reply: dichroic A's with its edge moved to the second. + short_pass = bytearray(detection_result("dichroic", 1)) + short_pass[13:15], short_pass[11:13] = short_pass[11:13], bytes(2) + result = decode_filter_result(bytes(short_pass), "dichroic") + with patch.object(fluorescence.logger, "warning") as warning: + self.assertEqual(Fluorescence._compute_dichroic_filter(1, result), DichroicFilter(1)) + warning.assert_called_once() + + def test_a_reply_not_of_its_position_is_refused(self): + reply = detection_result("excitation", 1) + for wrong, slide in ( + (reply, "emission"), + (reply[:-9], "excitation"), # Not a reader's reply: cut short. + ): + with self.subTest(slide=slide, length=len(wrong)), self.assertRaises(ValueError): + decode_filter_result(wrong, slide) + + async def test_a_scan_ending_without_data_stops(self): + """Not a reader's sequence: the idle status before the first `24`, again after it.""" + first = load_filter_detection()[0]["replies"] + io = FakeFTDI(chunk=4096) + io.replies = [bytes.fromhex(first[0]), bytes.fromhex(first[1]), bytes.fromhex(first[0])] + io.replies.append(STOP_REPLY) + feature = make_fluorescence(io) + with self.assertRaises(RuntimeError): + await feature.detect_all_filters() + self.assertEqual(io.written[-1], STOP_FRAME) + self.assertIsNone(feature.excitation_filter_slide) + + async def test_no_spectrometer_or_extended_red_sends_nothing(self): + for field in ("spectrometer", "absorbance_available", "extended_red_photomultiplier_tube"): + io = FakeFTDI() + feature = make_fluorescence(io) + configuration = feature._driver.configuration + if field == "spectrometer": + configuration.spectrometer = False + elif field == "absorbance_available": + configuration.absorbance_available = False + else: + configuration.extended_red_photomultiplier_tube = False + with self.subTest(field), self.assertRaises(RuntimeError): + await feature.detect_all_filters() + self.assertEqual(io.written, []) + + +def make_detected_slides(fluorescence_feature: Fluorescence) -> None: + """Give the feature the reader's slides as it detected them, named as the vendor names them.""" + excitation = ExcitationFilterSlide(4) + dichroic = DichroicFilterSlide(3) + emission = EmissionFilterSlide(4) + for filter_slide, slide, name in ( + (excitation, "excitation", "Ex TR"), + (emission, "emission", "615-18"), + ): + detected = Fluorescence._compute_optical_filter( + 1, decode_filter_result(detection_result(slide, 1), slide) + ) + assert detected is not None + filter_slide.register(replace(detected, name=name)) + result = decode_filter_result(detection_result("dichroic", 1), "dichroic") + mirror = Fluorescence._compute_dichroic_filter(1, result) + assert mirror is not None + dichroic.register(replace(mirror, name="LP TR")) + fluorescence_feature.excitation_filter_slide = excitation + fluorescence_feature.dichroic_filter_slide = dichroic + fluorescence_feature.emission_filter_slide = emission + + +async def read_with_slides(io: FakeFTDI, fluorescence_channel: FluorescenceChannel, **arguments): + """A whole-plate read through `fluorescence_channel`, gain 1000, the captured slides; feature, + result.""" + io.replies = [ + STATUS_IDLE, + RUN_REPLY, + STATUS_MEASURING, + STATUS_MEASURED, + TEMPERATURE_37_DATA_REPLY, + ] + feature = make_fluorescence(io, fluorescence_plate()) + make_detected_slides(feature) + return feature, await feature.measure( + fluorescence_channel, **{"gain": 1000, "focal_height": CAPTURED_FOCAL_HEIGHT, **arguments} + ) + + +class FilterSlideTests(unittest.IsolatedAsyncioTestCase): + async def test_slots_and_names_are_the_vendors_reads(self): + """The three filters' read by slot and by name; the one-filter reads by slot.""" + reads: Dict[str, Tuple[bytes, List[FluorescenceChannel]]] = { + "three_filters": ( + FILTERS_RUN_FRAME, + [ + FluorescenceChannel(excitation_filter=1, dichroic_filter=1, emission_filter=1), + FluorescenceChannel( + excitation_filter="Ex TR", dichroic_filter="LP TR", emission_filter="615-18" + ), + ], + ), + "emission_filter": ( + EMISSION_FILTER_RUN_FRAME, + [FluorescenceChannel(excitation_wavelength=400, dichroic_filter=1, emission_filter=1)], + ), + "excitation_filter": ( + EXCITATION_FILTER_RUN_FRAME, + [FluorescenceChannel(excitation_filter=1, dichroic_filter=1, emission_wavelength=528)], + ), + } + for name, (captured, fluorescence_channels) in reads.items(): + for fluorescence_channel in fluorescence_channels: + with self.subTest(name, fluorescence_channel=fluorescence_channel): + io = FakeFTDI() + _, result = await read_with_slides( + io, fluorescence_channel, measurement_settings=TEN_FLASHES + ) + expected = with_nominal_edges(captured, [fluorescence_channel]) + self.assertEqual(ftdi_extract_payload(io.written[1]), expected) + if fluorescence_channel.excitation_filter is not None: + self.assertEqual(result.excitation_wavelength, (282.2 + 371.8) / 2) + if fluorescence_channel.emission_filter is not None: + self.assertEqual(result.emission_bandwidth, 623.2 - 604.9) + + async def test_every_slot_is_its_detection_position(self): + """No RUN through slots 2 to 4 or dichroic B and C is captured: their RUN fields are the slide + positions of the detection's `24`.""" + words = { + (slide, slot): [int.from_bytes(request[i : i + 2], "big") for i in range(10, 28, 2)] + for (slide, slot), request in zip( + DETECTION_POSITIONS, + (ftdi_extract_payload(bytes.fromhex(p["request"])) for p in load_filter_detection()), + ) + } + for slot in range(1, 5): + for dichroic in range(1, 4): + with self.subTest(slot=slot, dichroic=dichroic): + io = FakeFTDI() + await read_with_slides( + io, + FluorescenceChannel( + excitation_filter=slot, dichroic_filter=dichroic, emission_filter=slot + ), + ) + payload = ftdi_extract_payload(io.written[1]) + fluorescence_channel = len(payload) - 36 + fields = [ + int.from_bytes(payload[fluorescence_channel + i : fluorescence_channel + i + 2], "big") + for i in (2, 4, 6, 8, 10) + ] + # The detection's words: two slits, EX_SP, EX_LP, BSP, EM_SP, EM_LP, ... + excitation = words[("excitation", slot)][2:4] + mirror = words[("dichroic", dichroic)][4] + emission = words[("emission", slot)][5:7] + self.assertEqual(fields, [*excitation, mirror, *emission]) + + async def test_unknown_slots_and_names_send_nothing(self): + io = FakeFTDI() + feature = make_fluorescence(io, fluorescence_plate()) + unresolved = FluorescenceChannel( + excitation_filter=1, dichroic_filter=LP_TR, emission_wavelength=528 + ) + with self.assertRaisesRegex(ValueError, "detect_all_filters"): + await feature.measure(unresolved, gain=1000) + make_detected_slides(feature) + refused: List[Tuple[Union[OpticalFilter, int, str], Union[DichroicFilter, int, str]]] = [ + (5, 1), + (0, 1), + ("Ex XX", 1), + (1, 4), + (1, "LP XX"), + ] + for excitation_filter, dichroic_filter in refused: + fluorescence_channel = FluorescenceChannel( + excitation_filter=excitation_filter, + dichroic_filter=dichroic_filter, + emission_wavelength=528, + ) + with self.subTest(fluorescence_channel=fluorescence_channel), self.assertRaises(ValueError): + await feature.measure(fluorescence_channel, gain=1000) + self.assertEqual(io.written, []) + + +def load_z_profile_captures() -> Dict[str, Any]: + """The reader's z-scans: of A1 without a target, of D6 to 90 %, and a brightest-well search. + + Each holds its frame (`09`; the search `0c`, which the driver does not send, kept for its result) + and the replies the driver reads, as hex: the status before it, the reply to it, to `80`, to + `05 05`. + """ + path = os.path.join(os.path.dirname(__file__), "test_data", "z_profile_captures.json.gz") + with gzip.open(path, "rt") as f: + return dict(json.load(f)) + + +def with_nominal_z_scan_edges(captured: bytes) -> bytes: + """`captured`'s payload with 485/15 and 528/20 nm at their nominal edges (`with_nominal_edges`). + + The optics are the last 14 bytes. + """ + payload = bytearray(ftdi_extract_payload(captured)) + for offset, edges in ((-14, _compute_band_edges(485, 15)), (-8, _compute_band_edges(528, 20))): + for index, value in enumerate(edges): + start = len(payload) + offset + 2 * index + payload[start : start + 2] = value.to_bytes(2, "big") + return bytes(payload) + + +async def run_z_profile(name: str, well: str, **arguments): + """Capture `name`'s z-scan of `well` answered by its replies; the io, the capture, the result.""" + capture = load_z_profile_captures()[name] + io = FakeFTDI(chunk=4096) + io.replies = [bytes.fromhex(reply) for reply in capture["replies"]] + plate = fluorescence_plate() + feature = make_fluorescence(io, plate) + result = await feature.measure_z_profile(BASELINE, well=plate.get_well(well), **arguments) + return io, capture, result + + +class ZProfileTests(unittest.IsolatedAsyncioTestCase): + async def test_z_scans_are_sent_and_decoded_as_the_vendors(self): + """A1 without a target and D6 to 90 %.""" + z_scans = { + "z_profile_without_target": ("A1", None, (15.0, None, "A1", None, None, 60920)), + "z_profile_with_gain_target": ("D6", 90, (10.3, 1968, "D6", 231622, 89.08, 43243)), + } + for name, (well, target, expected) in z_scans.items(): + with self.subTest(name): + io, capture, result = await run_z_profile(name, well, gain_target_percent=target) + self.assertEqual( + ftdi_extract_payload(io.written[1]), + with_nominal_z_scan_edges(bytes.fromhex(capture["request"])), + ) + self.assertEqual( + ( + result.peak_focal_height, + result.gain, + result.well, + result.peak_counts, + result.peak_detector_percent, + result.average_signal, + ), + expected, + ) + self.assertEqual(len(result.z_profile), 144) + self.assertEqual(result.z_profile[0][0], 15.0) + self.assertEqual(result.z_profile[-1][0], 0.7) + self.assertLessEqual(result.start_timestamp, result.end_timestamp) + self.assertIsNone(result.temperature) + # `05 05` follows the idle status without one of its own. + self.assertEqual(ftdi_extract_payload(io.written[-1]), bytes.fromhex("05050000000000")) + self.assertEqual(io.replies, []) + + def test_a_height_below_0_is_decoded(self): + """Not a reader's reply: the brightest-well search's result, last point and peak -0.60 mm.""" + reply = bytearray( + ftdi_extract_payload( + bytes.fromhex(load_z_profile_captures()["brightest_well_search"]["replies"][-1]) + ) + ) + points = int.from_bytes(reply[23:25], "big") + last = 25 + 8 * (points - 1) + reply[last : last + 4] = (-60).to_bytes(4, "big", signed=True) + reply[17:19] = (-60).to_bytes(2, "big", signed=True) # the peak there too + now = datetime.now() + result = Fluorescence._decode_z_profile_result( + bytes(reply), fluorescence_plate(), start_timestamp=now, end_timestamp=now + ) + self.assertEqual(result.z_profile[-1][0], -0.6) + self.assertEqual(result.peak_focal_height, -0.6) + + def test_a_reply_not_of_this_plate_is_refused(self): + """The brightest-well search's result, B11, cut short; then of column 13 (not a reply).""" + reply = ftdi_extract_payload( + bytes.fromhex(load_z_profile_captures()["brightest_well_search"]["replies"][-1]) + ) + off_plate = bytearray(reply) + off_plate[15] = 13 + for wrong in (reply[:-8], bytes(off_plate)): + with self.subTest(length=len(wrong)), self.assertRaises(ValueError): + Fluorescence._decode_z_profile_result( + wrong, fluorescence_plate(), start_timestamp=datetime.now(), end_timestamp=datetime.now() + ) + + async def test_a_z_scan_ending_without_its_result_stops(self): + """Not a reader's reply: an idle status with byte 2 bit 5 clear right after the `09`.""" + capture = load_z_profile_captures()["z_profile_without_target"] + io = FakeFTDI() + replies = [bytes.fromhex(reply) for reply in capture["replies"]] + io.replies = [replies[0], replies[1], replies[0], STOP_REPLY] + plate = fluorescence_plate() + with self.assertRaises(RuntimeError): + await make_fluorescence(io, plate).measure_z_profile(BASELINE, well=plate.get_well("A1")) + self.assertEqual(io.written[-1], STOP_FRAME) + + async def test_out_of_range_z_scans_send_nothing(self): + io = FakeFTDI() + plate = fluorescence_plate() + feature = make_fluorescence(io, plate) + refused: List[Tuple[FluorescenceChannel, Dict[str, Any]]] = [ + (BASELINE, dict(maximum_focal_height=0.0)), # the reader refuses it + (BASELINE, dict(maximum_focal_height=25.1)), + (BASELINE, dict(maximum_focal_height=8.55)), + (BASELINE, dict(gain_target_percent=0)), + (BASELINE, dict(gain_target_percent=101)), + (BASELINE, dict(timeout=0)), + (replace(BASELINE, excitation_wavelength=None), {}), + ( + FluorescenceChannel(excitation_filter=1, dichroic_filter=1, emission_wavelength=528), + {}, + ), # no slides + ] + for fluorescence_channel, arguments in refused: + with ( + self.subTest(fluorescence_channel=fluorescence_channel, arguments=arguments), + self.assertRaises(ValueError), + ): + await feature.measure_z_profile( + fluorescence_channel, well=plate.get_well("A1"), **arguments + ) + other = Plate("other", 127.8, 85.5, 14.0, ordered_items={}) + with self.assertRaises(ValueError): + await feature.measure_z_profile(BASELINE, well=other) # type: ignore[arg-type] + self.assertEqual(io.written, []) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py b/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py index a2b9ad36ef5..b0bc12f5dc6 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py @@ -427,14 +427,12 @@ def _get_plate_and_wells( # -- plate block --------------------------------------------------------------------------------- @staticmethod - def _encode_plate_block(plate: Plate, wells: Sequence[Well]) -> bytes: - """The 63-byte plate block: geometry in 0.01 mm (u16 BE), columns, rows, `00`, well mask. + def _encode_plate_geometry(plate: Plate) -> bytes: + """The plate's 14 bytes: size, first and last well centre in 0.01 mm (u16 BE), columns, rows. - Y is measured from the plate's back edge. The mask bit of a well is row * columns + column, - most significant bit first. + Y is measured from the plate's back edge. """ items = plate.get_all_items() - columns, rows = plate.num_items_x, plate.num_items_y def center_from_back(well: Well): if well.location is None: @@ -446,15 +444,27 @@ def center_from_back(well: Well): last_x, last_y = center_from_back(items[-1]) geometry = (plate.get_size_x(), plate.get_size_y(), first_x, first_y, last_x, last_y) block = b"".join(round(value * 100).to_bytes(2, "big") for value in geometry) - block += bytes([columns, rows, 0x00]) + return block + bytes([plate.num_items_x, plate.num_items_y]) + + @staticmethod + def _encode_well_mask(plate: Plate, wells: Sequence[Well]) -> bytes: + """The 48-byte well mask, a bit per well of `wells`. + The bit of a well is row * columns + column, most significant bit first. + """ + items = plate.get_all_items() + columns, rows = plate.num_items_x, plate.num_items_y mask = bytearray(_WELL_MASK_SIZE) selected = {id(well) for well in wells} for index, well in enumerate(items): # column-major: A1, B1, ..., A2, ... if id(well) in selected: bit = (index % rows) * columns + index // rows mask[bit // 8] |= 0x80 >> (bit % 8) - return block + bytes(mask) + return bytes(mask) + + def _encode_plate_block(self, plate: Plate, wells: Sequence[Well]) -> bytes: + """The 63-byte plate block: `_encode_plate_geometry`, `00`, then `_encode_well_mask`.""" + return self._encode_plate_geometry(plate) + b"\x00" + self._encode_well_mask(plate, wells) # -- scan and settling bytes --------------------------------------------------------------------- @@ -631,6 +641,26 @@ async def _request_data(self, values: Optional[int]) -> List[bytes]: if received >= values: return replies + async def _drain_and_stop(self, what: str) -> None: + """Drain the reply still streaming, send STOP (`0b 00`) and drop what STOP left. + + A refused STOP only warns. + + Args: + what: what is stopped, for the log. + """ + logger.warning("stopping the %s", what) + try: + # The reader sends a reply it has started to its end: drain it before STOP, and drop + # whatever STOP left after it, so the next command reads its own reply. + await self._driver._drain_rx() + await self._driver.send_command( + self._driver.CommandFamily.STOP, parameters=b"\x00", when_idle=False + ) + await self._driver._purge_rx() + except (CommandRefusedError, FrameError) as e: # e.g. the run ended meanwhile (214) + logger.warning("STOP not accepted: %s", e) + async def _run_measurement( self, parameters: bytes, @@ -692,15 +722,5 @@ async def _run_measurement( raise TimeoutError(f"the measurement took longer than {timeout} s") await asyncio.sleep(self._driver.default_poll_interval) except BaseException: # a timeout, a cancelled call, a bad reply: stop the run first - logger.warning("stopping the measurement run") - try: - # The reader sends a reply it has started to its end: drain it before STOP, and drop - # whatever STOP left after it, so the next command reads its own reply. - await self._driver._drain_rx() - await self._driver.send_command( - self._driver.CommandFamily.STOP, parameters=b"\x00", when_idle=False - ) - await self._driver._purge_rx() - except (CommandRefusedError, FrameError) as e: # e.g. the run ended meanwhile (214) - logger.warning("STOP not accepted: %s", e) + await self._drain_and_stop("measurement run") raise diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/test_data/filter_detection_captures.json.gz b/pylabrobot/bmg_labtech/clariostar/driver/features/test_data/filter_detection_captures.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..965222288c173766add1c1ef5311d5b1d091af69 GIT binary patch literal 64726 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 z zCy&0KtR_glx*7UtoLs3>&Sz_~By)%d*$Tk*2Gz+tj7;r8QFF1CMp`{_od+ZKv*xNd zif2hLY7ZK=tv|~+ou9n(zC#LOuumE#9s2m_{;GwdKF_^oeWsZR(koHe?%|APv`U5M zc?6nr(N^=MbmR@`4^*&g&n^9vFc18Am&6IQJRvE!$h|4LexU__IOX~3d%hXAjzcLD zMTtg!K~s{_WInjs>B0-NY?`R->SC?l`0~Z^uLm_atLWOwr@mhFa)zw7b?;!Gy>XJV zQ=MNMVNid?{BykZdcT)iKH-LwobhtA8A#ej8bgAF-l|7}HmL$CKAS^P>@ zdq2C8XNo5VD2*zIOJCJ87}Wc#@IrcB zCfDUSeq_t$SFFyaBbgRogOVGp<)Vib4U{$N0=+CY90zB;#NTsBO5N)h@-l4hj)}ef zHkEFyGtRU)V;Fi`%VkQ^V)jsvukqTi!8N=$%34Nh-|m;-@t~n2FVymsrcr%knZT!O zeX!wa6Mch7jZE{I^;v{FcMqxW=YUDvs9p@&1#z7E~<0$9Q02&<3146 z{FiGUR7*UYV!o`qXM78^Mc+TiOH6x7Kd2#p!qCaR6~VolO8E746)lgz%uovO^6J+I zE+7o|X4}}MZ*@?vQ9atbG9#`dp8fpB`ODxsHueHQXc_wt|6K#2KU&7)Qf0{x?#D{0 z2AP>r%(J%>wKIcrGR}V6V)k22jBO^VRT5&poj>b1u6m=zi(;seqDIJKt0nDl9D7fZ zCKE1K)sdX+{9yLb@`@D}YhV|Qs`;WX2AWmhbU=Ki}*kbm9s-Pj`gk@FpEm+(g8 zFN3lN}A|;x;prZx8Vb_ny~d|EWWvt!2S9=WUa}1Mr}GCn65e!xg3VA@_E|6-o$8`smu-Di1GeAWRz*Il*$wm3et&}5Ky$IfE8;AFwy)DMm37RH|8iJ<*G^hi#r3Bat zF#fU_&x#$>BdUl*(ZM}ucFFn}Qq2Ix1MQ0sW!2#t#kQL1trniqW19K-YX%IU1S1bT zt3ST5(9EvWl5RUkc}@BZ3etVr=M)T)1+qS!cnCD*Qiknyc3m z-EHSpryiN&xq5w9IYMp!`0@s7rZ}16j(Vq7p+rI2h&NKsfk$P1=!KNR$CAUA-<6tK zimUgHuW4YF0WIryzdm7@wfKEu`D<$^Z~v&+7Cge_tD=%3PVwcq(bWxyp5ShtuiHYw zzFg~)0(VC#%@y6hntA$5#sdS$O~Yu-Xad5bR;oLOY+yeyBGuD8ah$I`Q(rQ2Gm$N& z#JTXHu;E zUeDL%ymy}G^>0VGxLmG#VUzDH=Jyf&dybgN{$%B zf}KPsm8#pY(2f&FmB}{g*feHfE-ic5!FrC0@T}$Sv6LAyhz2z8 zeCo*O_UN32@-C)Tg(oQ@JcTLMJfAYpG7T73Ps%&m`7n zFUXykYfii_Ti+F7{BVn)$!np4wunid%J_m@Ny!eLxYq9pCnuJEdLKNs!vE}7H;jTD z74^mZ_e?j(zW20Vel>k%x6YpB5)ZOkG=1#mWO$?SoldxV0NW9@=WBv zyXm$0nB^g@fBx{>@5MlX63=>1_AzP#Pwrc=B{MQ0z(BRovl?ntWo^rOQ|oRoLS1=a zlgU+|HHv?^T_p>SkDKQ{6dZEvjVuXnp3HU_LI}P-r$7_6)Yy5&9cll9pcWP-x&c=> z&zDD=kHueeYutWP51q!5W-n-?;qWHXfZRQj$4b)fWWJ(aENeSE@$GUK<`?db*GV+t z1K)Y>cwbBziT5>PE)#n)Chl3bBp@FUu%$eWF8wz^TCH&AC?re4r&f6;dafy6rs~PbfKJmc(7eSIVwq@cG7FD%N zUu1{GOyf1936lW?-@heL$t<036=laCV951s>+5q+*)v=FK7Y<9zsRd8xGlRhl!uv! zaw2J}DASYYxq|PGm+SY9&G0|>0RU`+P=|ngYn5PULIDAR@v`p{%cBpYjo1B ziz=bwyaIj}CDy3;UvzI?Mg6s7-kAHe>W%0$&51)VbLkDvKE*$R#u@4)pNYGq>hCn3 zIa=zkWd5z;8@!2zpEXIMTQEJv(MBuRYBg2lR)+?UbRGJ|R)Iw=_8ei6_A<&B`RT#D zV~A$K=30ho==W2_Po50FPjMdC`QQ+|R7V-D{k$s53_Q(ATKdW>@E8p0x65+eY6s8U zzS6wExO7&^WgpS4aUr2nHFCo0*UEU|>b@PQiAd=QgooK~re)g0sRBL>9(V<@dpe{q z5>9cFd!ysRs+1nWc2Q(4=pBoWe$n%bztV`~yfLeU8{Z=2~?$zs+?RpRNEmZUJhspG@-?R_(j)rM+b z;%rz=?OsGsxwPl5hD@DPf)56ICUqLjNA5rAu5kQU)x(cT zqeCgZ6z+=lH+?L>Lzfs7=~j}|e?QX33j1jCyQhl8-gmzpSmCC%F!YrhRxKJ(I`^Va zes{CW%Tw4yyp`bMx3hAp$J$v6-zxCGNKV_$^eN9)7Nh3e9ndHK%6NPhdl33}g*Srw zi5Dc!Zzn~s0MMdz=M=)@7HR*mJXN`)pWHZRHyRD(ylcBrbFncFA%C~fC!e#TzLcq7 zp>(D9pA={}P?b=A>Rczl9rbd*z_nUDU{q;XK zg1=HhL|QNU2BWb#NSHf{lTD1LEB&szp}D-G{)4HUyj=h%<79k?=~)5z_h9!bLkli= zEl-AspxvSG@Z-FXKn;SwnEvNR;%WA_hb@><6E!$u{vI+*d3jcO%$O-5s_+L zx&1R~gNh%)zLXh43*M1HjK8rJ6a;~8t3MUV(K*QT!!^3;=TTYW`eNe!FZ$@T@&@x-;O=h&u~Zef)}yael|lBKAk{3X|Dw~2zTi{ z_rU4^ApmtC9Do40NPu2FicG(F4AfHCu^W6h-zFl@V~{ycJ4_%?KqF=m%Ldob5nzVt zXrm!5{cj!gdrOOO^1qc@-_3y2iUxa)*2`gh4>A2gUpx{4r0`5S2Q>=c@kTdDp=++a zhQO>ucTG4KfU~5YMI7}-;{zrczW9D&^C06{9zgQEki1+Q{{s^Y_PI$n-Kdr-rk_;H zfjnoR4!WXBR=kYzKGY+i8#qxjXO2_%hZI8A0^l@t)~xNjR&|91ylA8DGuPyl6@Z>VQeSMk50qB1fjz8iSY7Vu z8lWT5F>e<7C5;B`r1Ic`7qCVR4=Ba(!u5kEaAC=n&_q%)dKvLDMVfTogp;SXuQ{>h zL{`r-kmbXq82nl#UFcNpea?cWY6hIlr(8lSq*D=e`Uj>Cbtc3DE%a23XFC{n^d9|S zIUfT0v%R?pZR$&))?lqX`4fP^7yX+FN~fALuv+6@V)~td#7N=nz^{zebm(f{-lvkp z^l5S+SbkO^`6NyFAn-fDRe>RVJLt>dIbe@|ZI~y~+Or6-Z!89%1`;wrWfB6|0~?fV z&x4auAPR4}@fMk1X3j-j8 z0pMUjtG*&$sP}ziP$(uW&;u5!M7*j7e%Kk<#|RpQfMGbnD`NT&PNMm`WZV?$e*kh( zZ4iKH1)>6-bm&5d7$nP+DS%7wdac{zdJS4$t6KwepU04?-ykDk^C+F!TDEXRqBY>) z0tC=U;6C_E_}9swA&~G^O||H-+_jh`t%Y>Q>3(@7FysjhQs3p0pD!6;^S`T-PX zUTY7%6dn3u;PU`j!JByqgqyvh6mbcpH3k>tlr;rm?tI{M1x-Lg5)jl1_$Ua{%id^{ zwOkQD&c_o^E_|^6pY%LW_Z;uH7AhDbGBFMTh!VLq;NAHy(&(DEKOo^x3v_D@gqD^O zPzlBW2I$IOrCeA`Ld5f7u_?f?m3$$MYsVGh!+Cr}@Ip-A-D3vpu_m_O8{oZba^B-S zmLl?nFzimI3%t?4L;JCv*`nf~1R$(e`9hw5tqQIS@yWGw*QYIMnO;)&&eWAX*AmnfpSXzHOxddeb7zpd2pHS~HluJPFt|P!}h3bif>fhgp*u&YEMr zVL@_ez>bGq6J~FLplTYbqRs&4HU;AI1i~{Kv#Y|9LG`mRm(bN|6LKH~Fj##q7BqwB z{@}0qqx~{i<$egcmy2Th$?avd-V$xP?wV&=PGqBrQ(*Yzwhwf${8nPZAfe-;C2Dok z0q2xJ6nVb8a{@wu6`)z34Aw=x_l5B?=tIjYy2Ausgct+3AodBgs(w3oBVeX0fmq~HL*)s3 z+Z;)z1OJ5ieN0&r#w_6G{sPWxl}n@F6L8in{lXZ_0v|vli;TIvG;naG>mGfsBAz(m z*-p{YZxyEQJ;9R<@Th{nL=*)Yrt}Fjlsgw>ACO|uL_P4D-W#6qq|N~$?)DnOHN4qR zh`Q8rmoTSJ_H|<#AYli|Bz-{}0AndFi#Yp{esB2{NQ2(!T`u639ztw_X4m3VyMG1? zRxoOu&&nEPL2HBcQjWSqZ*@QB`QxS0dvhVk1lSJ{bfDwQuD}rrn0y+66bfd+*3s{< zU;_`z0QJX!d;_9@Wm|piO$EvJpgss_)DIV?BH4;Ha?ZEC zU%-cPh6C61NAK}l^XG}3!EIPz3?CnGr~ppB-UyiDoYUYa#9CC~1B4eMA2?J&S)1hxE@`+z*InWQ`V7J{2JE)7MHyjsj(9yy5kQ zdWu>*3);mj5Q}a8^Tcp*OI68Jim-q!F4lvF@bcaAlM)vBamp6qVjq^Oqtwuf9Y*-d z13p>nZlD$5+BthZng827$Y0&9bok@v57#%^xSd(}s(*_tMl%eu|Bt1jfunYMP`|CW z&XzQ0m1ZO2ex}RjoQ^oB7G#cTx{`ZYk#KWol{PLdfi`yfL))p;FNua)l6XYq#$!R| z_nTf(nIyn)CGDVxXAtD=`9;n@S&>kQ<#*-il51e=DG`+w6?lu6mbR0jQIuA zh+4-b8T{eDl=Y&o7c^aenl^{;N($I`!=owDwc+tz#-CfyB(3pVf}>UB%O9IwlL#rk z+UPjeRK1Icx88PXmn=M(aL@m@s+f#Smn3(K-;IRmZA1yWCX(71&;KY;?&+eDFYG#V zM$lNHv+ymAdV*>d;d!&?NI)LNa^JdzarH5&j9R9Xb@)T8hjBa+m^FjivH{ln8@@21 ziIchiZhwdQZMnCp)|H~xn2_O1n-|PNbx<1Y14HAVJtNK!B+D{HoG;bI6~2Eqbs{w^ z$1S`yS|PMYTs@VKr=@&}aM`ElH|YTz;>~(mx7Xc0R=pnYUoh0=Ze4)t-RZJ%*Fi@` z#vk-NQT2ulZriIXLHi7QbFEwEW*n&IYQeeItLFEv+&6yr9R|~{t2Ed~+xL1pMAU!# zw#7eA)~V*hb#bnzVcJ7^stfNFiZ`Y)Ps2Qi$IJ#!6)TQiOx1wN{298e$ovqr9d9_k zYrkxgVV}&xLDT6|F-F6x-Tsz4W_L=cUC#CPsx!N!dI*2s=R?auPAubiib>jSUB4rL zKXOQ@2fsPKsdGE4Btp-$y;x2V~;wmZ9L zUi@|2^?I{H?Ji^1U$2%rSZU(W8#kPnB-!Ta9-$hdr`x zytLUKNllf;;hJ!7E~OcIzE^a9-nBA+(sIFi*!DBKR&lKSfdnIwxlGYr=71{O!f|tD;2e7`&(mQV^U=NKQlAo-3#omOP z3;Q|FZ3^4=dlNmx!~Vaz*!jy7k~cOHFQ6QC1&{s86?gv>rz|IL8f*I}R@xDyRo1ix z|1!kySM4p>gsEPxItMDWhLLouJFJ zY$UoJ2i_I_5LXBeIdqw`a_MSP?&5zr7arl>)EiXd>mM=`U@%n$zFVstYD40}Fr-OTS~+X%Q-#7C2ny3?#^CKcxGluLI~J@yx0|KX6(SC*D;p}vg;2l z&C<#d3fX4U%Bzp5ZssYXL6TKFaNn33utW4dlXmR8+mEcYuB%_WqM!rqrkge3RQHe6 z+EK5WV<>Tp60`ssnemJge(>>5OYvUDDOkh1aww@VQE zUrxvMLLWpvv6j2X_KvF!2v$yij{BT?HTCEx^Fpj%>Aj@MJo_t`p6>lBwkuMRsM$I* zMXOefYK-lFpVYsRPL5F|#!kI3&>O!i`b2a}O=0+&Wfe@)zrv@+B8K@?5IHl!{38OY znRW){kEdDJttM~vL3Qq*uf_T5FNS`PH~|km%a%KjZNQ}j(Y=NZR3v8p5nAk5qjkP% zfb-kwm|J>U$5W$5@6Yo|JgulISCL!4}nsA>l1z)@@e%u(%r)rK<=3B1>?p(M$ z)|<0<>s@a|y_sOv(WVlUhF$m=B`x`pJp75WoQCCn+_*%FFq!!+A#q^ACOgOPU5Y{N z-0^2-DiY^^zH>FeKYGpxYZ8Y+X2jj_iL#v6m8!?8N3_Q3(yfBWNt+LP>{+<3#{$W5 zDTA`_UCMj1isc-_pA0JDN3{}Di zEqd!Df`>mBx0oonT7vVY*&nyw%66Mnz%Fd=Nyz2k=$e)evDkS>*#)=eSVAzW5c{M{U4Gvhp>|hN??kC+-l49t$`U8OLA1Y;>2_lvFxzV zZEbck)$pjzazQs`Px^rc$C44Kplsh?iX{JaxZW*!Y@1P73lqmG&HEziTiwK0XWM-O>9+E7*H z@Sld8TiFuxXrJMDs|V2oErR76|N!H zvH!@Uu1+{6^dD_zPHS%@zUSrO=qu^|@!>_SV`fLs3uJT75ORWkS8!m(2J~PG&C3$g zn=Bejx4^*td}5&I9}(;qUS?;l>Cc%b_T6vp^%&Ype<1hTiv22>Eg7mlGn9j#btidQE>2U7 zTiu@!Xkm)vd!^5|l`g`1#8!@*`~JCg-y2@53mpitp50R3jbQ(l0|!&iHR-Uke|UwM zCaa8%=69xBT??b9mu9{U{3 zs468b_gq0(ZEU1TC&}c{Zz_2@Frt%l=~@v?vx(?aF+r-3s68CX*;x?hmPePRPDU!s zJ{`(-F@1oQoBuQam2$juXiS`YB2g=&6mh9GN;2JGBz4|2h8NY>r9H7h{{70RV8W1# zsuFp6wc-Mk?srPBRY|<}QO;FUR#9%VRk1|twSW6NU93m{b#%pYUFZ#u8z$V8)01!y z^eXPxxM9{ar=L5wL>ueqy~afjs$-VuX~{C%SXF@+NM9Pt?a?nwymE6et*)Le7ZZ#y z)rQywr)N#+{hRFdOK{!u4dmM&?XeH)4KygP1SguqJ%^rontX?@B0k`ga!jAVT1~O} zJ~!S8AQ^6(7taE@uaKt!3tsjcMSrOgnp-^9jBahSYEHSta~v_|)+h zg_F|{6aGC&#Z*yjU9p!D&#n)PLp$j@4(zRC-)+BO^mg`G@yeq$bBqsDXgH&v9^<5W zt~Pc%bM~>l?$64>T;6_|#3`O-j6(75@sff*yIx-GW9@_m@$-Gkhnm~oYBgh)RwC43 z^VTYR_53X>=Gwz|ZM@=!F?r3`As@uC`9IV8{LS?KD>&AJLB({%EQkkXQ_St3$E0J$ zO~}_M>2wqqwQUJ?ipW(r?_{(dj*Iyi1HXIevEzi4&!ySi9P8h;vjdRdol4bDexi}j zk0q9FWcg1XO*NIw4;~LcBEU#X?rc|DUbm8sp=swBA*I_ad@p1z! z-yHV+!K)*&p#D3j4x9d;OZPG2( zrKTQ{&^S;7HUJDC4IO#!wTAGRb@<`Q+-12P)WkkjvT`RQ57oPw^>*5mExpLxis-Bmlm<>jU1CZS zH%HjK&3gnsu(+6ZC~=f;Hh7Lv{t>*s-d0t)c+Ayo!PL#gRFwZC_@VSf@X$DE4t44Y z1y@j!oCABFuo}!XAlciEbk>7E zX-yi-I^Wg5#h`m0Rv1ET0;^&Gej zZI0Dxx*dy`$n$`UBoTWL$RgOIKl*ac*r4{&PR}1CCMTXQWocc$&2elV2(;nG?CsJb zAkzxyxw-5eyy2-viG_%%tmZbxX?sG;>Q9~u`nK-r!?RdLGaH||XOJn&eNo)S3A2?0 zd%RG)%dtqR!w(9Lh;8?q|2bXC(`I$ra3h}w%h!aCWoG|A)mDMo!D@8=WqhgC>)~W# zW>-t}!44{hl9ZIZG|&>=9vYaiEWS74R=EyGt7K7rH+9&#!UuI~!18x3n2GzpNKbh6pH2?nb)tKL$b{7&F7@L9k7 zRmIZ3u5fjxKymnTR970w`+7vVo9U71jlG$M1Zsqr3HTn4t_`}MltQp+A1jCFtFKiD zAH4e;WBBRT;e6Gp14kKe|26555!3ICaNM*VgyXP)rHd7;S&BFzU<38|Ke7pL!&@dn%LHa;=p7N5OLbJi}eY!dA+z8-UCVDwh{O)Xa9;ktZf?w5+#QI~n9Be$8c z^!v<>*WV~gS9o&Vs(P=?SscunjlpYf;Ei|PZuRR5g=$e!`9HWzD-|dyoXe3idqFOJ zL$^(JJFiuptOFOrV2$AI-n8w>^bPKg=$ru5+I&iz#W3x*=1ckR}(NSRT=5s{; zN%RwsSw56Q-_UT9+c?q9tl>!$;|rzSA>l(7kPBx&Lz@vwjQ>T2y`bNp{z|wDAnGEw z&*;FD3-$l^ZlBJe0F#cyd4^5-S>0E!f!tA9|J4}FP^g+}e#&HXMSGeisN2%K=xND; zRoG8s@1I2@fzl_BxI-l@2#9MkuxwZk9|ZpmYLFYLnsw04DX(7Wv=y3r8h!V1AVj8W z%9rfU#!d=5P-vPF&>U44LDQ^m(CQlOr2tI=d)9D#2~i$H;6Kfkxm5cRYC!E-bPT}o z9NPwr6Tk*Ic0&LYc2Q7g*p$0(7Eq4DOtsgaSpl7X^x(boK<})_ZK}gd^C!`F18D)! zefox~2QFxLl+-|K%1B0kZ4cf_yKH*{Rntg-e+mCPk2t{1K||cMT=*B)?CVSlqCPB_J=E_%5GBTini~FDsJo9IonL0JGsvj7$W$L%=R8 z%w;ZUKSKhe_d)>v2*wyytv^dRKgSU+M%DU`_kLstsRD~;{iYuNi1*Kfa}~*7Y8Y6C ztc>!HMF2Wqj_17(#Ibdc^pe&(QCTe(@4IXj};-|7Qvy0s?vc4y5$(om5argZLTt3XJOsKDXj1}7CF@zBQz^A9E0U)R*k-jy+Sz%IK zJ`lae1?_>7`pNL%q|(EV!UwSq=(`7=k^?!}Ea-nfz;!Cu9L;jDh;)(DU&Q1AaPGu8 z%gVHN3QhMjcoG5@w;bd!u7RMxiP}2}VbhV9pv!PE8%+SIeC-dm&}E%z0MOySwN8Zd z!lArg!ed@&^8xsUeIA@442V_>_V#SSO*UnARo@uy9)_wpiLSpi$RTO1<&4NNfI#14 zcvP4Gz>OOo?{?JYqqvkAYJe@^Fb@#5OhBjhnPj@+IPf;fnzO|~LLTNQ{Bw2tPawkr zwZ<3-3VaeMzbrJcTEl0dJ~JacY>2Sqy_JRlfi&@C>Zmqc%v!^N2!i4NOi?-bJD^Nx z6YJo*#d7$xBKee?P8jIMz&9*9G0=2~pJPJ%2U5>_)x z`a1#eP!trrWeff6b|~r*fMCq;$9xxf`c@d<08Pq-a6sHQABs>u&8GsnzHlp**TOs^ z)*UW@Na@JJ#z!pZGrs?k5*Y|PTrsd*0+Iy!rO-pE+Lr(=Cob6>U; z(f7^?OGv!xJ^{Izs`M8Mf&AM5PWKjPIKqb&yVUKV4il_8(>GByz|+C%bdLudTKG(e zV?p#E$R^AQ5{J{!ZCHRl;~E}m?elsG_z_H)ALVmpk3brvkCTsknn_CJA#n4(hE-^G z0;(sd{5{AauX?q!wif`4ox57om#YxUS0S6Ld(bq`crrke z0ZB3w99gMFfp8Tm%>1mu+F~b6sf}p>wu3;E8%Ym^8A~32e$6Kdh7$LtynXe*v8Py*hbI3H_j^a2rl-Hq{Q* zpzZ^Yi}WR3T-V2UrThQ2%$O2)4-~h zH*Co$S!MFS+qHaV&>FaNwJc!}u|YK$@Rb-hxEN=PGWkm}v_&XWLOSpbH@nSFa4|-$6}MUFO&}Fu5FTs>n#WQk zo0~-(;NF&UJFI4 z$9ul!GeG9$qbU>u^1K;n)l~w?rP3{=&*g;EG6SVoRG`av%f(x)~cbC7liZ?XIYSz(oMgl`|1AD9EJWV}i@IfgZwq2Hmpi zApu!rrqUqn|EpveYc&V8!`V7G!q#SLe6SkE8x38BgPmTN*s=YRP9lXzJY+wOo2rGI#O!vEOmtlD>rX@sYA81}9P_ z6O35bM+4}rC?~0DcnxM)wY~Rcoa7@fIcaURuLym(f?L4T>k@6deZRRlY(Le{k}KZn zl+Q?(nAh*YO-qb&x5ryNSf3;k^?Ed}O1Bqt&^^O+SL4H{m5cU61sNK-Z%4D9Y}#|8 zeyZ6)W9Y2tX_wNPBxlWK+c}GjZHIPC7X&PfrKuixvMKr3I40V$I-!0PHOuB#>isL6 zOe(dxyU-Gnctbzop2_iDu5b7Feoi9qfIUn(G5%1oJt1*?CPD44VP5%%$YdoRu zoqGLb2DYvL$$xpMV^4m1`06$%pNQORDifXL+^=Tj6t({Cuv4>iw&#)KKK`!Gb@$Y6 z-Q$r-PYcgKcfI}KhM!Dyvs!NEW_Uu%cM@ZCY&7!DrcKIL1lN5IsRz$L7`VQ`#!VWi zW&PR|e8u&iQNgB1PfR~@H)iA%6kgcx=#xzFDR;e(TR!8i+x|6kY=_gr?cr?OdXX_H zjf7-4M$7e)gzvks=Mp*@$u|2W2xrpHJQ9C;vwK^HouSi*m+MvCEH2P8Ve6cv9QIC+ z7KGI8$ji&zd(kMwNNd;!p&yA$YYu(2Gq}dki4Zm6W2A6s{J@il-6d*er}8pV!=;KW z@>;{RLc@=?3N& zVtl*JrG&-G`IR#hPn{pOSnr#-;rii1F1smiVT+JeS?#`=Gn@ z@S$;+#h4;FZE!2KA}^g6_6p-3mC`|N2l<5kndb`{uR9}84^!a$4c+#WHWN&4@5Xk~ zAE6h;)yfMtNaq(ORGIs`-rp7fi10jRoiiP2ErnYI&?MZnby7Z;q@HogHwIZs9G=0^}beAbN?-YEPwJsIl6w#-Nf z(>RbQ*Y_;W$avUjkBCjl4h=k2yWkOPX(8ObsC!+8p#9AFhtq$-5z#Xa$GLYlMGQTk z(a@{fw~u!I%)M!9TFdrnkLPjfv%ibsncs4UOXHLuY>ja)J^4we$+_Nc0PA;Umo5H4 zRAzYIlRwfn1`9FVF`F1#pE-Sfd!=K=)}ogGY|~9L4jB6Pkyx_7V6X}xEba4 zNyiHNB2669T0_$J&OeI-Htb)2YyyMF7yr?A4wR`8(HfRBPhD@z7l$TSby_9MGPgL3 zWgMv42$R-$l7#)KnCXAgCemboW^qBZ?ZStoX9v{0j9ujv`Onl4(Ww#z(buaBoY=z?_DSvP9t--vp^aG@&tV$2CSBZ0{+>ugfJ02XowZ1AuUDj(a;%4R*3r_RswjFg zEEn)GsJ%`PD4DLRqvObe?Mi-Q`4=rCwJ()fDHuCsEVQX{r(+QA-Xl?srZyw;qAHb` z`wz-@xL!zKwk`^En&W8}J!{@xnl{sg8eWR#r}Iy<1=F_Uv|#MM$sAbM*8UAqw`@B7 z{UXwR^8Jl%9#}e*y5`_dD>~{}`bI0ffn7OmPE*U0DqS>yeY5NCRh}I$a26ti_(IkUWAMM}R{ zS%kRu*tYnoE8}@#L#*k8&!b&j8^~QZf46eQmrPd$+Zlf2^OQrj0^&9^OUpmuibO~2 z9d~VGp1x$fmcGt-VG5nWu9H|t+wj!Y>;=K5P+*wSx6#LvSol!1Dj`+6P!O~iJ3|!Hc{~-Uk}FdGR-B1Yc=?zYAl1{hr0&M9HX9hLwMPx13J+) zNA_0H^yxlWt-`8uI0PrPd3Jhq*4;m)Y@jw(d%}4qwf^olR~u?W{OF+jNZ=lURmMQR z!^6UgZSW3%>V~f9l)@f0cNWvIe)5^B9RZvqzDH<2I^yM;y8DJ$_5FWMhNhwLGLhWgU>OF!(S)6XJ9qs^l5L^E8BwJ5zRY%({C`rb zwQ3<_C12h&U%YWR@DcA~lI;y?n&zUHrP=RZ1sSH9=gsSan;lNz{(->_*k+B)UocGA z7wi*^>@B$GP1@J9H5Ctt*kP0iB~Nvsc&7p9fu7GEif2$^cT;4gxPkM(LUA1l#yGfUf7KgRmV`Q-x4CG zd%9M#5s|lj)m%pT&osy#Je{C3PnBsXT%JPvR|Pyza}rjBWy%dOx=lz&>v7Ucq?$76B%WTg!e~RupfAs%nWT{`mZb5IA4bC#jHtI`51q+t zS-mu9oS%KGEeehySa`nYZLu8>pFK6jekE;l=mz#F0w2;IGZJOq@^85Gu;y&oFl=Lz zR~}6H4oXjTbWb;P2+yidTztfDP9L+wUHyh-Tp_)5#8nX!YlGoI5et_y9!)-+ms|eU zd7|iC(#KBShw#O^dW_No?qx#2gQa*IXTmpf$e}5RjQo(WjiX;DSMn+>M7e10&J921 zr>>>nh@5EnZ7|HiJ;8fTS#72ze?fg++VL&@$@DRal|QpSrcP9qyQ3R%&U1^);qKEf zNee?4##hge?3nf8AxGBDZlhPUgo0vn^{y-%OnTiED|*#YJuQq3rQfP_AZRaN3P1ZQ z+vtp=?UBrgAO#t#X15C42L!H+W81;WQ_kWUU-x&u(90iuLHK**btz0|^hZ{mCkR_5 zCnP({OANNlyUDXJ-B&mxhka= z%aWIvduTRGhgk{b2lt8NmhPT^*o}Gk(u+XaSKIMY8n-=}<8D|E$q$8MG{#-$F7>G% zNinnuQ$-%Pj&fvQie9&?A+_oDt_N>pAJl%4y}G530qZ<-XfnI1~yl6 z4xZtdpBwy1e7s?7Amv7gicOB~!i(*iqqu12QY1V}iDtRpb~+ETuAg|fq@UCS&MP#$ zJTztw9a=IPa($asXZ7}SOe<5a;Zl=%yG|LQZpM+e-{zboxIS?r$-n-#`k>3SZ%Qs+ zKF6`suSk|O(J5HD(?nr*?a$61i)PsShH3N#ub2$K zRt_3(&HAz_uqMV%eh?SENjZk#Vlih!91ZbDsDC6~<{$Am^OP~hEpCZ`WxQ~Fl}4>I z^3_Oo1D8|ijGH|~Zd8*dK4XhAYjEk!)VQKf~)-4<9F>u<(h z1TN&v3s{lOTaBeVjLG*rAO6aOU~egD^it#c(N@XPH-VP~Hr*fY&%;)j)T2L!b_`hF zi;dVlDXDNqhH@Et7esX(-OgP7bgmxrVA=Jsnd(yU^~O6PUi)R`sHPaHPf%ggj)z^b zyr-y;#e_V{ukE(Q&doRxM0(1<2ZqOH6JUnwC3)uslCZ}P&{E8B>$B}wtom#qHP+8- zmt(Rb`K9gbg&{|R`mgNHPXfo0qmw6Zyj*0cR^R^tPxanx}H@hW}ZcfO?WWKnc~Tc|SOJ?dvDei1h9 z(zS9QYLR(cMvfk9C@yarwUN8=Et=UzHQ^_Go7^LwG2i;b<|sa5Pgn0um7^9+LgIr0 zYu>c05A!^&THR)Aw_w9`ZAR4eA20LH=X*wdGaQX=EVJTy_VA-4c^XBEEZVqZTmVNQ zmnb+C%9rGx)mXNeGkRvUWyR6v*yF|8pK>AUG}mPl)Wd`hE^aYdNre3ibDU_XOn6?q zS>l2%bJ{WRxT$OVYp=wTiyL8Wmm5dC9XT_>=Gy(arBD*_MlSikG1OgCRh<_xJ0 zYs2Fj>?LI~^D4zfrkms{O(&IPxQ~tUaqQVs3+;CWxeOQ1Q0KD?JDE92Qj+=oYH)V(U`_U2EysJthr zF>|=sWLbBrIKfztWP1B2V>G^`_EE(W{&dzH+x z=w8*EpN{Kw{V-<(igs*P|6{}^Yjx_6T1c_frk%BDY zY9qVi3pM*47Z+c^u(Z07xU7FWc4ciGaD_=9{Bm`Cb-YxE-vr%2Y@q#ZjdqlXN;@|^ zPx3o|k-=sh>SHKTbyK_&<*e^=x8NxawSAl(zs%{T=9OxzPq{5RvcysU{$E8Xsl?P- z*n6wF+yNt~)eL(Rx7sl}wbH37Jp~x3mI8nb`E>gJK2EyS_LnWbx?VQ<2!Hti!I7jFx9cra7FueAAS@f&#h z(>@_rFeRQXNLh}YYuQiJ)_doXc417I!QieVz~9-LY`sAei1^i?LtkAuB5ia1U4D&ST^P2PASc5rzgvw(;kvuyA+ zF6gh*Q&n9(6Lt*)j6-*mYwRckU?aYx3J?I}*nm%RIB+8_Q~M}+;>nKb8sS)wHQLGK zhrsU#3GBnNg^Kqyg3+Q81mvm!M*ac;yut=%SbrRy!01RYAS&1MVX+?&vgcZM4C8-- zPRr3WZ^sM>5>Qf2m_b2s&@$I?67s8D!k!w0PA@_7+0UW2rH>QX7tof{lYB%#`Hug> z48{$`BQO=R z7KE~a|F@x2cIe+m>~l9|UiEm(){zdVEm*+C!lu-SzOkDOXqq?o!^i=x1Y~9M37V#n z-&2%S3fK#*8akhPAF`_UqG{&v4-rL5^fTxZsIy=**=V*HCDnhRnyQJrc@}PW#ad1v zSEg=@7KJ^22VFh_wh&*MsS`!Wa6mggu>tLyo`ILNHHgY^>_4cA8-_>j3_JrDJ6DlG z-_ThH#6AXgU7w2BqAhNk(==b-iQ53BeLt&AK2hohI2b=gqH@?zaVV`P%od$=<{XSD z^7S$p`mS(jtv6K7{rOhDS^urL5S4%N9|T#&A7lJZ0Tt;HUK(U;1lVd_HM2z<|BKcj z|1x?>Ksw;upVMzBAY9Ql5oVc4ltDNi3AfR*mSb4aG)2ZFG|0PgC!W(4zjgug!s8a$ zI%bxlq>|b-;dUo)o9^iQvb~gUncJ>;Rf+##v6SwNM`(}>uxH?Qty~vk(YtmEJ8& zEW)?k_`xlue_DJ_|Flv9-_J;E52!As-v{3cy`v9o>Mopxi!~+17DXBz)nj;Szv$q+pA)}22q5cOF&)$U*-lj#5o(4$*xvt3+M?- zYSGXZ{p7Uokzq%mfGhgh&@-|J=VK9g1~BW-p$sllz`!v8oMu4fL>&7e07CKj&BlYv zSm6{Od$C||z8TPod>6PFiDc+E$N>^~>eL88Bp=~Z28O<=f-QPez;jHBXG2rnq*uJ) z+-9JBH0ZeFNQ^T1xkq`qfB`neO`r-?)?cb-^sq0V1SGE+2d=v)4FnKwg)hHCNfoWu{!Zs|f4R~eCppeC>VL{;Tk6_B^Di#Ek1Np#>rulCRkoMLR zRG0q}0(X);1d>}2uRch%pjiMU3PS03b6FZre<1`6VO z0cnm+&BuHLy#GH=AQuc@fB=Sg1)_Kb{B{8gS6EGBfj6w21xfe_koz~k}Z;eh<-NEFQ=J<{n7eAgI?t#*05k|LmxT9LCp>ki;lv@ zW(QNTzz94b?l{m{2usAV0pZkU!L>p=je%(Wj7I{VQr;7!qYU6KtBo_=;ClhX4I!Xl zJ9z&)Fmf4GvWCDvPNlHhz}m9%zd4#2*M;sytpMJ^zrV)G^@$Hc>VS+47hvnyz?&nm zRYKR$1@SG$IS$}l`7V_E{R)_YvmZQv3)1ts`?C;0X(3lThJ&Ri+%(uY4>b9AyK>Y^ znJj+{DAfoKOb8ufO|ps001`eJu!l0U>H=0B&l-Uv0LL?c4g87eHoFR-x09O!NZy2<%gTjQ;KTi{i10sC(a_Er`Iq%z#q0z`70Z=sq=%rz! zHIxCcTjlz2vC3p!#CIXcUxI4*453$w%mQDHrhwbX0zn0UH+(bM&?rSbIUI@yaObQ7 zKKjeBp;G%{dCy;wlYm2s#fBXefZA9=9u2%ugO9Qy($=82@NUO*Akm!y7_=vDU z4d9H0*e9k_kj_|wfYkL2LL4BDZ;HGxfYz19fj`7Jffs0TN7EAezdwbRkPg3Bkt_>) zqZ@;}_y-cldH#XEqdDip3L(b4U=cDIP7`3;xL={N&r3Rua(rlq#DCyV)iy9A1QxV= zu;Y+i2!fA2ApjjG<{dJ&@DZ;nW(zLvndHcgHT;1#S}QF=Rfj-Y-;i$w$;lGq`=+3c z)c?oWo4`Z;{r{s#Sz7F=gg%xmg^G}Etl460rLwo7tjQK;lw=Eq2r_u-vp-tY51uh;AOTF%@1bp(XEA z>HW}L1F+I8I&z=!Gw`b(0EPzvQ7dr|0Yk)p!#}VA3sEqD8{J>RZ_gRr<46GG0*E;{ zAfMlzRNzT&gOE1^hb1(_p}~+>2zws}BO@1V6aAi^)P+QiZpEAiR;dF*KgX%Ge&}5j zXvmlzPz490u{ki>pRaBVt>d>zAdiX!L;e;Ry`RJa{sLawgPkx_emp#d+=BkVKx_tr zzk}KQ>r>}|*Hov2oyTjIK%68Z&t5o4eUfxz1_quE7~KG2VMh0Jq2x^v@~OZZ&GQW* zg*7*jmJ|hDQI?=w-7*k&vgxAqo8Qk04!mQ4rx3-;T1@1B7rR+?|{qUbaM_pbiRQZe7@D~WPN zw-b*{a13qbrt-55+4aRg;zCUi{LGT#x!086`u zgH&xn|F&>imv3n2!C$@)W^FrFS_qbYa|G?8R*kUG&a1gjZx*9VO^@kq3M}r}xJ>@d zww!$k|MFDJ?{?7Oolp_)BkqD0=IlQK2xFoHgnw!Fm3Loo>o=_Z??K2(9Dbezs z&b!?Eiza!0ly6Z{WJh7S;=0oWyx5yiwa9Vt$MeBVMihKO<8;#V8_l;GKJ26YB6Z&=35`IN>I79z%t(7OiBrl;}r%Wa;8p zM_Kmj4j~^rnHbxMXNxo;M|G)dE)kT_q?BjS~dDret zu~X*O!NXOu#R3Sovo_wm+vNckp4vl4iit-9`Wd&Py zLHm8fKXVg?ORh<5>{uDIy+qVqDTTU%TBLim%{|kOOng1!FEZKx@LVUGMgzL{fTccdu65~Qx`9De@5aBZ(?c71Gdr+irZ%P%g)Wn{!{(3WH%C97dV&u&a?V?` zSdnrlc;E1@m`ie?CqH{cd!jvlGjh3=c{{CxZbkk%U|ckwGky64hxr%n`MCS47n2uG z`L-V2hcg_ao*s_5J3am&?{PzlU|*&tiO2uzN#jnQYOQv&-6r$urw$Xrh8iA*O?h0U z0y9BV#uu4MA`wc;9#wCIh9kcz^u9k{ot!STq?G3sLFlnjsOP%c6RkSEuR22P_O~nB zdX|{+14Gsl*;`vTZ65HP_|mcQ>SQ~6W@)k!X_D`L)R=T2Zj?e{6|v84AO=|UUj5@N z)XDYN)n#AS<-W{k7UILY4JWrDP5S@+fQ61K*V zByPUkSkU4dHvPS$GQ9blcBB*@qXt)fKEQiq+%eR;>>uaGWji^=JH_X;5L`-X?c;Z{ zHa}18-)6%e^l3?<@3a(;T+^pDf7aw|{>#^Ih&*|>-fVcZ!rpMZGey7#$Ko4$V!>-I|W{ks<&xompIw57ru4BLs3It?+!qY0P&aYrVYC}&E_ z3EH&hCw9%Y1vU(QP5&lVzTCWl`09UL)NnxNH|hN4gj)2PBI1DlG2CROM*ec5uAhtz z=4iv0ddbps_NjTb74^`^O2xm7V4^!Z4c|r+FJC?WYLj|4u>$Q@*_1;AJK+VE-aw+U z1fmUrzrplPspDfYn2oq#1Je=*FBDc;bUy&MIDc^Y*MLI1jrQ$V@s{usiz$U3ib(!` z_QMj6HbPJWOlv+4Cne(~tPnQ03zugbEf*OW+tFq*&ODIX7-w4AS1wVHfLEbJpQ9N@^R7w!H zY>#mZYH@eQ?38T<>iXqAZZ7^A_O)>pm*4z$krEOaVqs>zV6C#W{|^eEzpS5AU!fU8 z-ue+acYMj|^zcqa^WoAP7JMNKs5dB6HB9Krh5NX&c*2yZNsk+F1P$ZU@MQ{a<7|A4Ib~GKJpQ?8HxMeI>rlhioo- zT&?!Rj*mubtn`@)l*Yqw{l%t|YbMJTLtX0xNDUoluJM)k|CMvJ# z)TS|H77%^nxI%-Xs;Lo>{B6pgGudWjmTJ-sVNQ z+YpXsbj>*u9t3M>5OU|LCwI~%U7DotlbS8!9cBgAo-e1b;&(mZm!aoD1E&u2Y)Pl4 zy}-BC6}JRO!@@;|0t2Xv> zL#%JnFEmRP$5y73Na$+Ge0gE`!d)tA43~$#B1QYO``+d!tfwV>k!0hYy0Jk}EhU(0 zSkpeYdtVr)>I-Y!@k(P7KBHG2Q&q~Fi-?_@HR#mS6)jmCb0T~z(zv9>|M*s%5E|9b zZEVOYw;0@z%cVs#cuX4P@+@o~ELRgbI$_({AhC3~d5AvY#dl%^o!deh8r>?yYHp*J z8$;SR4weSSnpNNIf5JK9o7)0yS=sDM2^#bNOnr8mFtoYZg6Ja=@hCU*U}U0TnSq;v z`r?^^Y&hB0^83Ngu$EywODf+{&HayWW2g>pGA9+*H?<6NZ~m#Z*Xo25&a@DIBo2vo zQH!lStrmnvjc>jJ%R;ch`o|XhCaaql6AjZ(;=}N;gum0~8<)I7(F=3GjPh01lSJQ^ z7a%9?F3f$S4Q#a5Y9%*x)8(wfEIRx|->5B@H$m1e(;J^!3Dy>YT=3c-yOHgR7;@q9 z(0lN=NDkWk@7+U>(yT}Mo?U)Ic-Ls5fLci=eDS95Tl!O(xSsB<7r)a=%a7>^`dk0; zetk~u%Z0O#8ao$)j1VXz^$$t7AEeZFsaqB|%RaDOC0oac;$PT4t%=&9rgYXKI^#CH zV#?VDoVD#ZPk5)&?+Z2NE_#@VcJp$L(5+_Y?JS|QTWK8|6Hj*c&z&D|@)C3xe-UUA z##42(%U@Sc$)g_~ev=eMd`kD<*BiMZ;O;b(p7WTLT1@A+*Loo zv*xdID_E0-HciQ=e16>)M9H;WVUt?Hn}>%n?^5in{jry~q0LRa=!Duz+=m_h(kYX- z#ib`|L(?+InIhY-FdDtI;~S^WzOh+d+&4w~WGwYESYOjPEsek=z}> zXY|$1`n)bV<6w?}d3C(gvHeB79wT0)j@cnxU@3vuLY~B2j@KZZI;QhN2NV9 zlUig``J-^wJAJf}@OV%HwRl@AzD`@M&QYAOY;yybKRGzFdv)2&t3gC|CtQE@(>mipty&KjCMV8g+n?r^P-oKkS zbGP9Wfv4px$Wbe&h4njb*nT`K+OMyaEvJ-}NjDwI>g(G;{a%!CNZxvV5&04)DF=@9>8m~Y(3qVs`KxK6WW&1OR=&WYo8t5q zXNnAd2z~iyE+P35QEy@~{ga5F(WUHfA9liQM1Ml2G*$Jb$soEj)pFuX$x1q}Q9TPW zQHO0kHeWrQ_^?7lXy_MaH&#WfI(<&S9c|lyBlNl+{kpBX1pn9U+j!Yht17erCRV0y(&BfwN%&D+z-HakCT~4b7oyme znn-6`tX+vAKi{x2w#U83E%t3VL_oKZXXi6d?64V4Dh_WI9`v5C>l-BxD`tNQI>?{x zOCVi<2j5hfyKSXqxpMH2-3cTg@?EFp9s9>uJLW7Q6R|ZoIjw_K_2Hd_#kBy{c2?T8 z0fFp|&skiF@oRoBMMD>l`7yTzM zbh25|4Tm=mG)GdV?o5X8cck6bc)X=GO~BvI#?R?vdHMpIw*5XdHucN}Ck9e~eTJ_G zeY39Zq9yY>jb(e)H=~_~=4@3JCXR7+l8z^^>7)glP(sUWiSlrIrBa?T(TGoroMBKZak?`ZeK<g~C)#61AB*i6gl2=a#rHJ!Eh{CeBNA>b?$FyD-1`RK?V6o_lqBK-8pB>%vZXSJb7tmLI_nPvi%hgftvicuAyP*>}idF5MeZ zCr6Tno-NEmv*&m49CC2K+)`FJz}KeBCngvka--hJRb$(DZNQ24}BBfF8q*n`h0U$$~ z5w{RkRR3f@AWmlhJiWRj<)Mo!0!iD9gJ?l_`TOt{Btwd2NJnwp0|G0;P~f9=j50Jx zg6gqE6_Dd3MU^4Jx8QNIih>;kdJo3j=|SGJqg#VFp6c{B!w3khqw%PrVZ11~aZgHS z+dN2SqWT~Vb^*NGhNeB}-rLX{$M+zK>b3%{QK4om87Zm^?YL9Wl&Uqb#T2a(ta|iX zgz(_SzkC?@|9&SjuzC*NSihCIhFo9YgynrMgX)Xckc>vyDp*1NOv8Y^4|>4`&z3>#z_W^POS}c-* z7u5fK9M=bOO-h2HqTma;cqBXOb5R)Gb1-oPQph;CZkutiyt5bBfr;NQTBDhhyL?p{ z#M@91`u+-9#5=EyF1mGm8>)I=Jq!(I6N5pX%Fq={1Vkpjfb5)K zuCONwzjt3Zkmf#i6A&3hd(f;i4tlOYg1wQWpVyEn3wbqi$b$MShQMTQK)~I=N)ouc zd=tXw%9DY308yQeR0;ks8&;ZLquktFQ3jq*o0^i)6qCT$yTn?_C%<^Ph zeuJ2LyZ2$J2FDP_@DV7Jn*zMYD~%3vGe9SZg8Kd=_o(#@f}4A53n2!7u(e%1cX^QE z@cXsRpXJ#DZVp9!S_2$ay$6Xezuc+SJ^V)?nchfZ)i$^gipCazEjPj4-Qe4Q*~h*0 zq`-hOJh4E8zs*3$HJ$*nepg0&?7PREfvVQOR2hq9f5vR88VA*LzPpu?(#WO%NtxT zxLz-Or8LU@d(8rtvY;E%Kn3|nCBd=r4gmlDB>*W%B?7YkLJSIYy3Z1dNMY~;5_RC_ zq61Sq$ix4H;{X7#L7cpzLHE0E}UKmW=$Y z-xtc&Md&qyAXuS@^d~@I@gV>+Y#hKIH^E?RKM6oZu=St-+T`;4bD#r|g$8CFs7|-Y zIEeH1>f1AgA9zoO)%*sk>Vo^x0o*|?07s2&$jS+L4f&&zpd-wIoMsADlDxg>pt39S zr~NMC8<9cJfnj8E6Y|hh4YWW-`VoNme;Ys=55QE!&=JG9r}wOY42a}|b16=YRbGBN zVKx~D@qr9_(hPqLt6W3w-542AO-mfG4{E*vVfSz-2lHioNGOnZ1syhSu*pV6tpETB zjBFuL4M=`2JlzrMgSZ%gfP_lM!HG10FtE*E{{Nl=JPd+$#P=Dt^dpe~wK3EkNd(DA zARAcN2Dn|40bLbxoiP57OJSi$3|UTv71Za1F_f;d$4mF^1`<$TkY?k*I`Z6xy2{DO z{u>z}w*p3rHF-!}4O_ria#jOnf%G0wL-QIL%Bw%634D6M;U)d>1P2Y~hBNVFc5ncu z4U%Vv)nY*xw({!q<~<@f6)XTN&A1_M8_Cdj0nVP@m<3IGG1e{u>er_k9UFKm(jAr3 zI39yVcpx4iy=j0-vBMgEjiI2RsgI1as6hV^O$Q>K5u0gXoEq9QsD%OVd-P9k@>NTa zcNhF8gv)6xhS(hLnTJ4o4sPKKbb|okO!$JyOhhjRZdJojM4>K}NCjY!EP?%i6~BVL zcb`5_GNXC|g8=#RVtd(MkYn#wxc+b0!h;@KFy|O{eL)xubs->!=_bG1JYfGao8hT$ z6S%y+IEUv_q&p}H4n+kuY$>GC?^40}zX(Q1+>ZmnXYi-J+i1@OBXagU0ya?8jJ)#z z&WF4X7*wW%2-$@JLkn;o99t$dMP?XRuq@E8%L?$TzX&k8gjfN0`aEFYpPjPsP(7n( zZBKR*;JNy}I~o`Y<)R%UmTBhw8#O)m{C^13#*i^`6o{V9>q%jR((0=%2+hARveuhwM@JXkkC?PjC8V z(p&*Da=rVpU4Y8w>=vZ*=j;|F%>|+{4Vdzd=AJoBWP<1pa$tra`dNZN*L>Hn#uISi zLjJZ2b5s)CWdk+})7n$}F1@V)R$5(Tip-$FfWza9_Uwoe>VXPC-a6ypz`p?kSmrjc zKb0kUnq~{=Wnv2gb9DT2vpm6A`IUNFwnHp&E07jrQNYXB!MjSgKV zffOPQD4h;;I)-V65~$MFh04|7VN3RVt2BCv2fjW3ACdgE}n_8il3&uG7Ni=>(Fed;}LW{qK8 zM|F=yEZh*7^nTXma|_O2Nj8hTXc_)M9X{h1 zvyR~7);%5*YjUbX+ZW#P#^tLDzEW^C{lZ-PFVa-6F!KdEH=90>nr`mz-Gr=L;X_l1 z*x5<_A7N>$_i6`tWN#P1>j?hHvbGGP|(r z6{0CW_VQD)b{U)>zjU!Zu0F(KKV5&nqqKQGeqp@dd|H9?hk$E%cA?ds7=9@uYyNLm zA3m)vwN$Xa*m2J@GWpiHrO89(Kf-a&!o$PD==CGLZ^y$O%pW8Bf@fJ9rM+tOpFRUy ziOUYzTTp+$T;bk7l%c>Irj}|`QaAm3=kWGgD%C-i2OYuQmfUZcg0uFu>4?rvy_^@> zg!#f(pQk$Rl^rd>V##G=^cw$@SKv(KjE#zHlC~w`LVME71e3_A({^usF5ED$c)ky; zC~d9ti)Gv2W3>O!_=gi>0@ccv-0>dJ&S4>`##+4b8Yb0Ax zw~!Kk@j{$izTK1a<`$|G`65?jn>-$F20g06nB6jfQ`meBlpD7WMYfoAEI0@h-&voo zd~|3d^;4+&@87;n1uiPGSG=v@DEv3Rp*)`esX~QVj(t*txz5ASR=2*x*oE25L*oX_ zBww+bq+S)+$#*)N#W^9x?@?SQ@u~bmvC-LX(fPu!sJFRt5wQilcGA}eZ45rd(#`p! zYLjw<4j05ftkmkZFPGrm&l!EL^weoq9@(UbnVTu+o`~0}Obp8VtrU&ueb;_!y`6Kb zaQAzjK6m<{M%f7yrVCLDg8TvJUMqOuqog15zm3P0y3UYk+FEY))=vzUMd9Rq!6W+wH0Md0I-giLgZD}73QMh_FdTQEaOxhSwlNzh zT^;MS&~7s(>t)*s@yp~mTfnnhqjWh_>;hJ=D!iT5D|ui1wBSd<$^qn2?XK|>H!<{k z!)Gkd+O1X$3_MEEau&=dW5ZQAxGbdG)5nW65d;;ZQ#=O4QvR~?MrYrnKEQZ;)>QR2 zQi`Z_Hye8%gVs!axl7dDI&0bRxTj`$c^NiMzEXZ)k?S_>$wW0p0gXFHG=(VpKH=v= z&y+^WT{8_Wu`wDifL2OhO48;7EO@T->-ZIYWxDr$zKn^swB55pLh&iugtuQI=YZYN zgq}SsQ}JtO=hVhN%X`3dzT!v=uAPTNHZKO5llPumO3QH{=TlwPxcw$Vr=GX>k)`G` zvv8)%#$#;!GWr~UKkv`x6wZ^rqMO6*-sWq;qsLza_t-4V`*SI!`1BlBJ>2MYZ21l5 z@y(XINiR=v8e^oH=vGwhWbt3g*e^RA1)RTk)@|JQb-OO>CVx1r;q9O7bMBnuWg1W9 zrk%i@7G61wE!>8wukzbh?&PN;OOam#r8YP?8=5UXX?6wqSUtHC&Al&!2mVl3)y>8k zmC7w{lI8M9wxMVD!8)xhheu>5n!dq*YtA4~H+AE$esHwd!xv+>IlrYQios|F+%^ZO z-lv!k#Gd3$j>GuG#Wq|(jh$GASYI)1>x@{<{w$VO);^Y_o8N?2Ot_1Pda?KuF@Lr@ zYBvZ?{%myjox{jOvD*n2A08h#ZWA6OmAX}YM@--NYCFAeXEwdO_UUxds2+48vQ=9w zRoY*Zt3Y=oP=gP3pWQB6Y$IP6Yzgq2s?UB$RQE7;PR=y-x$3b9**a&s{}JAF`SDx* zMn71Xqr2rUWWRllNh5VWyLW(J5j7IO$!qYnC@4oYitgoQ!`}GqURdNUiZCHWW3#F= zjuzID$|*~PkBezXN^nOPb4zX0<|J{?^0G2mEJR`h^Jj0q!X<~x#D;%*#V#H@i?OxX zPxB^Slb+N0XMu0FAn#8t{q)r!av$HVlDCPghb|>oNa-K^@O@oZA4wMF6}^k_7Y#*o z+~VNi`BUB(A8UJ~TaX~P1&fHi^iWx0D{GEtDG^mWkJtGiVJXJ(D{>C`4?<&*`RZgc z{o*U5tAQT%caV|#My-{hlDzCnx8 zTCD%EimuG!Tt5{Wf5m{fuU$UDTX_k8)l&Tn%)-TFfs$-$rNgfFZj+RRvn;7=P}zbA zQ%H}cd?<~+ATOc8p;M}7d6ob4ZQ+xNoafyp&z9RQk80xRmmF=L#Yt>kXc5~;e<7X8 zTgoXgn9S{H!|W7o%El3YDpXh^fqg?u*3z7))vo>R-nc1GqITq0ZwgeUHI!D43V!U$ z+t32hmqhslROb=AuV$Q%J~1Z`7VGEYgP;wV4Ri+u&IDR=?W?}ko~}6 znLqqvlYh3y?AGj!e#he7sfzuLO&x=|2V}3`GDSv-onjZ5x5dN%n``Q0w+ zApeYnBlqUDl4x1hv@b-3_LLMM4sG4vy{X|zb$7UC&8#3B7WwQopO{Z(xVYolvaNk5sxU7jKdNt6GVxs;@VrZSn=anj1WM)j9^JXK!5X zW7D>18iTs1r(AhO@@X1#$G6a@j@b&sO+w2K$R0CoN@tajY}Nkf#)!V&+)=>=rx@KN zRFctInwCm&-X(VTYS|~wGe(D2pqJuSQRx^-1hOjX%q!K?8;fTwd~Mlp7u6t3xE6d)|4ZKUN+)+3wQ4^bq^x%#AO%YBeEvY7Pe$zRQd`1`v z8kRRa6cW@Qge><*$~=S-&fp{?Wy9g{z_~N z-X0}cr|#zS?laKIu)CnV1Xsm}Q6soZ6=x%lfdy(vz9U*$`k^~qm}mG6gwjtffIO|H$b#H^1sXwdRMi`ICl!XZO_t_Xr{S3a8Z8 zNRQHHiG7h)^L_Wb{Ij%=?Pf8CHoW?gZ))z!&fs)gg(ceAe|fJ^GMP50A}fdgz-m9I zlh`ajo=ucXvM@D%lgUYmd=P@)ZC8Bz15fon-kqE`Mx`e5n*P-+W|zSW9uSbYf$XMb zBx-HPJu~F?;2crp{@dWqdsMzaVzFG&%P9!yX11K zDQ=tlzK!L1+QLyQkM#`2B<<5H`_*u-0#uAwU+%Uld-1BxH+MYKRhlih?E?rmoxg;9 z<%_TT1v@3LYV`NXt*zGM+*g0#U$}}*7q6JAv!9cwh<$ggKb*yPg{0Y$c^!RBil#aH zE@H`a|FS*mOA~r(%k?_SO@h?~J+d5%5v&FKN$nyURdFXzO%f>G(`6$yJLDnP^eoOw z-B8P?ky{~{;sjb6x8o7s^Adf7gqhtd(F*-cQ=dkw{hE*5)S%_Ce3%^ds!>R1TOuD? z2sYz*zo}U-!us^LpKxr0#bacX9wJ^vYsCP+K~6sY0mJdc^Zkn*m#GhxG(|0^aj^C9 z&>he?B6yuE`mP(hwW)r*+}HUjJ6w~f zo)U4eFK8q9uCu1~0#a1>iqeWfmcEX0`4!1_Q}U}hgX1c&L3P=6v1trmhDIGTI`rkS z{Tusoxh~kEP^DuoL66&Iz&O(7hSS#&@s`@Vyh0U%YAU=95+7glEhL}1GXL(lnD({2 z{1m6@cmLd*k8c+xWnc0MmGJ#=vW_lGGdTSZN0jSk2dRE*F<5-VmJD`aG%gmLWjkFF zt#UT+N(w&pG*yz_tR%U0+BXczmyFu^vcol7>*qo}`Yi6*_I@|pyKZ9@w8Bizp9AQE zK2qEZ@xf)Z1T1+jB4P8=;SE_mzcbSV(-*p~o7_2`uFqr4<(jtyS!G{lR+mJGg8bq8 zPFoE!DeN3;@}mpW;s^;BxZJF9qS-BJj`KE6X`5HnN8jg9OMKjU^|Fn=KV)Ulw|VY7 zhT_|7yzH-bOyQ+}gu#nf{!=OMHt;1$IegkDt;CX3ylr*ge2@Z4brtUTw(*=BBAyu< zeLQacz8amj0Tp8QeetXcL~6WtJG&9ZUAsfRHNzC#sg=$1dQ6&T>w=WsN3dGoh>h|p zNMdK#Jw}-`TgFSgGK!AH_kKEbd+Xu^oumQRR=cv@_@syX+Xv_;(EKUuh z;Yf(#@{OEx<+ZIt*!yNZV+nSg4bpEJ6zh&ghfMM;H@=84?@;{I5>oiSsq=yBJq`N~ zTPcoLy1R(YqIS7|T9kO34|ohknJtVjRr!HQ{2c5!%t3tw ztrD~15S73c$sM7{BJt9`0jbwby4A!!r!LvjTJF&QVVL}BCDfdp9LKpeD@Dk_nbI_7 zL&As~M@Khg(XEQt;328ILwy@BxK$E=Y&29QkDrGx`mM*Q)Q^xyP8;qW&UwJ|3z(;L_N-+zz_kRx(TRp<^ z`h$~a*4z#5zuk9`^$|XN9S_1*^o~Wy(M+uJ3hEObin-M`kx zwb6Cv^Yd=yv{=-Bi@x#-eDn%o>_Qf)<=A3b(1+je%@nh-2S?e?W9sUrJHu38h+=ls zGE!}Ap7OFQ^w0E%^{T{_FrybA@27KEos2=eNw^;pud+gTi$@&VkdPF;h3s}ko$UyW zzRmx!=$*Y+hwWYaU}yBNBLjoJ=S1q~)H-`AQ5&kq@p?4X6zMbVd>w7)y;TDe0^f*q z3vbVr2>fzrky1KYkxhLeYHWo0gZUcL{A&qjC4e?U^6lli$}@zWRY5{!^f5ZQC@ zjV??fnihK~9b}t&{E=3c0M*WpBMUBQl5Qj1&?!j8{R@|Re~qcyTmJbaEQgrb=UF@ZD;>oL+7vDR>(%|woK zV)ZuqK(`uDx>{yIQ-=@QGN zSSwimCf?6)*jiFU0}ByfGetbS#&W0yAT_hoMnn(t%oKvUogC+?JR$G|6fyVqCqtIw z6>?Ah)Nq~yDrJ``67RQ<`s_(T|0@WV=X$Jog$<}{31kDM3b7DwssQFeqnL$1p(Qiv z&Bk5LnS9Ga^=3Kr5t3+~Aw?ZWHnE?pL#VODS%>4!G9b(K6ewmu6K9H(X_W_dW9vXi z@;du8+wG?sgg?<#akbmu0U#xVVwphgdXH+ZYTE;`ue@dP7!zim_hC(K48$WSnd1GA7;HWVB?!OBv_hv_!(Yzw&%Cn^2M~_= z`qhY6tVVr?WS9@IrVR32mp63w<^20orq!^BiGc-Geg7C>W{rP_!D^%U%pt>^GW zXFu14^dkjwRy10N)69VVfSvY$Ezd@ik=!QAhT0_KaOQtH_JHwxuZ_6QpwVB$nZuAt z!7#_T27*h9miHl(h`*eRS0mE2Bf*c*gFnF%7QXvo`C<1(p z&hnc+GtNisRCDeL?6LxR1MM-OdJb}cj5`nzj`a-_b^$%MyGJ8{Ar%8Y(@MHGkFy&V z?*yEHQE(J=^NDp#koj`L0}wqtOv4#2Y2U}oaMOtZdNunw=9(A?h*Bp~#zGcrZJqX{ zfJ@xhj)1Dcz!A{@NtO-&TiUaAuP`UmzWNW#>`J{nJ`Ljisw^J08lm% z$c7965x)~XCEE(f@zs#a_B{iOPf$XE*T+P#cf4p&#o>!*CFOLBUeG z>-GH%j)=f2qt9m2fQtE?GqdS2)k_9I)sZUV>bJ|xfdD1~cRsm3tI1_^JY2z<_ zz7pi{FrZFLJkCZh zE}dkaf5ggf$cPz}D3BayIYcf3wuA?{DsH=2Np8?#w1WS{N-ya1FQ^*{4xpIL?2pte z1}soS2fmp-D$ML!w-qHmGq%kTc?YLVGYEGFqtq=Bc?>gK zc%A9tb()0#;}S`CB5#9%O`^r45N6482v-EqOl4|h zKR3wZ9E8LZciL8Aj6eelT!T3rMc;At=`;SI$DtVUIfTcvmV=3 zkZhgIW)jE2InLFJx3=OK0|^&+CL-+##GLyLq;;7OY#VZ%`zvJV7Q+!*F1C<*rzU zaqFkv0;Ofa6Ty41I+yvL8n8b-lki*`!-V2_oh=+q!gUZ{E6Ah3JN!8BQE$^h8mtV$ zNwtP5S(AyEE^pC_{xH#Zy+a{ncFeALD;4L5Awmx^8-W;{CUR%5c<2A{0U~Cj$2$Ip zi;SY5I8BJMbGGc8QwT@*fa@fA5rz->dO7PL)gartC@box&x@hgvjN)c`wDvfzEvrz zd~EaLzc3S!)RrHpPoc~$e;K>0vLsq2aT`Y@6?vVE==Gyjl}|as`^EFj984zsRq!E4 zlBd>w*YBSQ)F{jPu^p#&?lGF8=|7^>ys+9cF^E7Bjx5m1HL+WR?%gZ0gmVFAh1BwE zAEx5aUM)@&PfK7+#f8*?i)IMxxP`EX6*yP~O93$~eMM3r8F!*F7?t5c8g>zaW6iBm z8D1KNVVPu0{P-&2$C{mI4Z5dJ-ws_PuQPhezPuH%kxS7l73K zE8Pt35HzF0r`v-gm?KwRs(akK-{Yny&ZofqvF1dV=Q!In?&{ z;=Og8@`{spwq8NlQR`g1Tc`EsOg!y{Sf)E_aJz_5x#oLs)G^r|jy>D98)tmz6i&*! zFw7$$=xB|4J@Ky1cbLt~o-ol6+Q}L%DLd#EHsZ5qSP9s96s%siH$xs3iMq3n%ejEw z40&^e{Ja!ehE{hUhd(YOy0aads_87EGD~|85YV-?pt=IyvZ;WTL}g0I zvVO6!*Mr1`kAW1efY6~yB0eB+1sRZufCn^9zt0lYXnU~2^`L6r)-z;zkaVX1^N?2> zO{;c85KgO|D*=<)>hvD2fw2_Zo4CHBv@{j+2@QMijaR0Htkh_#hnS8yVa_>(JsMrx zZL0y>WGR42Z_Yh~eSsbK7Wai6NXD%%2qgwe2T*T!VteCNmOVSmt33j!{$nKV<3+;W z8S8Tq$17+zIKC$&)_N?e4E8xati@oe9Y&_T)kQ1$`X@7*r`sx)(&^g-Ew3g|%DY=p zMVKJ$E)KJc8+RYu^u_eFIl|Y27auKQH)V6W%Sos+iC&91Ql;=^tySu2d=1h_BM?rh z!6!EeClZaBV~<$qA0zmOdj$1=ra0Bmbu@r(A)*EoAYAC2M?tloyLi1C^ z+VS4n&zz#04OvhTwhuMU@<6Y3Mcx5!kr)y%~XMU;Ek4PbkOa_hJbMqK+ zda+hT;viBIoL2_NJVV{WmFp2sYe9Ejtl$zbYxAy6YHJjy8fbMrWXo;NJ*<}I0J9HW ztYU7;UJIhgc-v+B)lqLCRrAU)bCraMPgrJrbPf8?&Jx-?kRV8DX^iWfCQ@q8Pf{h# z)nUg2Lf?2$?vEe%aTDk&|B4XOZ?1ae1N6Z2^HNc7fgm-sd*W9OK~hkmZ1BjR_d=+l zdvjQbH_=Pp(IJWw_G#m%`--hUU+iOCO{j_xijszF+<^P9Z~(JgLAQ@Ss37$Q(C>zf zDqQP!OdLUt^!BAKE3fU|o7heom?Z0crcEhMSI_14B-D1_nDiGam-$FbnR38+Xk1cJ zpX-*X-|+rSZ^#a4YQS!g=0~_bwso{&0vfhe)RAiUD_26IvM^wZi&Pnwcs=U#oJ$X= z@$b~)N$D$EgP}IwY(qoeY@qd##zJ8(`{BM?1r?~}>(U6J51X2ng+X~Wg`KXQ{xibW zH~29y+pr3QTMHsjPLcEMxYXaRDfGQr!$-*BIs-<_V45l`{e7MdIPdZCfM(^fO_-jD zyk;P66K_39@C^0}XbSWqlB(|si57c>Vf=HdCrH=*H&8X>8)gT;J0=EDe-=lS(R+z- zTE$%Nr}`l0YJ$5c?CsRIs3Ynk^$#x2hrq3~e~JJ0p8FgX?|m69lI%ep`Ir{z>9;ZE zJlDHZu}$fdM=X!PiaZh6KefV0kATqrKy}fO2EP@q$~fHCJxbd7sZdP=b!6}YF0m)% z{;Tz`L64YU=rPN*2sIoI| z@O1FH=xI4S;xkZyav;l7#~B-zY)$zTsH7qGia_}+!P8m}N(DAHl%~|(#cmn;3>N(P zn&m|b8JpIY@qN%RKL9E|f=veG!&D}H-*=kC*zL`Zo_YwA@h$hMec+`vxiTOsPXpE2 znEY zM#ent_Y8?ZF{=742e!wJN4}kZ^P0DIvdk3M^3rNHRWpmyJ5uvY3-q<)U3+k>Q^Dlz zL=mGWC;5LTXa8TEEbno`s6DkO%+dfFRG%`##V16>1)PyI2lHbigbssRTj0ZMAQZT6 z52hsIJOw`uyIRf;$^S0`{hydD4b;+Mbw57TL89zC3Og?_W$d^=iUDGhz|XK9uMiz-ho>?eI)I_ zTZ8=j!E4(<#`nHDgg4J)Fbv+N{P%EpbY<_#-b)Dx9}p!inGo=hjw_q~w_!2?`o8zt zzzX78&LAT#CP4*`V)rd*wljq{%KDH=4ED}d(0Qi*_t(5p4n-z02aoTxLGLoJnb=Rt zNy^{T5G@x3ML%Re{ouNKE6^~e(Fb<*mSQ&i_4RRs3Ta<~lnbTp*DOYhdF^!k-L-;L5V8G_y0o2*kmN-MRK~nKx|fHe=p^<*u;jn7Fr)cORs(2xWr7kF+=QmX5vz{qIiW*5oaK z+x{0!gVXhrH~M7V156tJaXv?D^*^e)bM4X0$Lmhl?m$IlAG*!Q z6VjC6&)#PGXxriPpwk;@W2!eVG(T!?9^+?6gv^N0X27J3uU8M<32}HfrEc^nR0($LMg4z$Oe@dK;@z2;b()1zc43lqcnoAci>5YU0cY`t05h2zIDmyB zy(e+p(c~@m1q9BiM?-d0+$<~>lAtH=VrF4+kQn{_Zuu-a29l>Y?uyPru$&mGsz%5? zJmgr~RZq*0&F8xMTTg&gT!YvQ{hxn~Bv2av`yc3Z@I*HsESTQBNsZL6-26>(5_{EE z8_=m~BvxW0uch*7-mado(D|i^dX(xxmHT-qQsd!E51%QUj}5%AmL@f1<0CW6npfV5 z5utX+KN=UDAQ8_@+5{Xj?vji|uN{2dJ(cTSbr37gZ%K}!%DHt>tC3x+dm#umo+vdwl&(zi8ls_vdwuV6;v_wlvH!KJVE{A*4{6Ji_Dk1>~96 z2FGQJWK3>TO2{FCrKZy*mWTE;x^|aOd{PkYe$jN<0ED%sU*8!65H@Bv_pu8 z?0bKD>iM3;C#Xr?(qDwP@1=18mPtaox+Uo*-7>CATtqnEL(cE4m>_3lbpMuO_AWK$ z$_1OLOO9t==hev0|zE(z~&J)2@fn&<{G7wMXBfA z%WYUr+VUnbFLnmAC7tLqvfB5$Jn-lJ@j%XqwGb^MaU%=1H6Sa@6 z#@{E|eA=*kq9Aj4L27w1u1PnE^SdTIc;9!y9f=!Hy3?HU_wT~Qz6-LU4JP1-c>4Ad zY#EvDk*HV^>Kd-J;HR0=iK2W)a($E{ikl_r+;R>(QiGxfAaRBZv_K79qTFxJSh(hn zc+9P$&Fw+@|54YM$2DztGO%#xA*3{LbDSe&nvj{mwhZ}}~vH}~0VjiN#>m%je`VXl-%cLQnJ^epa4@L08d2%P${sN(Ix~M- zlk6S)v)W92Se+3ge(S05I_~3mQ(5h)7|8q9r8w2lGkX45s;A8;+x){swT5iz@MB^h z78@rfSU#PuGCsVe->AvyaHn`zF1P2Ed*1;!9Ose4O5nq>W=b8F8R*K@-ICQ6=`?y!7<r9!xL4RW3YZz zQ;(*$+akVmwI|&FjoRwV79w~Cj=1WTH1`ZmX~2F~3TJ8~L#O=v9vvd+|Dc(3C(GN2 z&mU{gvQF-LjiLBPN4J0*cQYPo1alZ#fDUm zrA)r|h&h#v*Jmj#g#m(lW4glkw{1_kVPtzs*t3JOgIhQUbX=S`ZB7Y~>J3Nz?3kFL z{=I^YgEr=5z4In*w|GnT{D-BDS*~MFGNmr4539J>y4t~S)9sb(@=vD-%M2S< z%vhpG*l?)#+N_9T$mY8hh?cq}X8wIz#AVGkig6+PY=2~DP-@oUxsHG*zovB{#?LqV zW49Kzxy3ND?(<^j=`VP5CqAQ(MtZgD543)4YwBo=dR}5WVS2PBI$xqpN;hW*IK@mC zddBF2XU-fecg%W}#lZ`(5$o%zNFe2rvCv{OwN!uvNZyn+I@b*@wUXwhRo zPVapo4Y1z(BVEEtt`X z+pEJI^>0+c~U``gF=df7l%!}1ypVi|;+j;IV7g^bL z;wS}S1|2neMKIKd6IQ7*{@@Nk` zi>b?cuY51MJX<;T6`lXaWqjTx|NINXbM0BLJS0ckQn2Ktm#xoqRU2O?Xir2q4dgMi z3Gg?Ge}T*R1}i^=^tSgLe)9W{*>ElDT%c!VHsMs9iDjMd`90ZE<%zbz;Vnn>g&)ts zRbST+A*tY*wy{NVb>8Q&nAtWhYS)_r47JLy2HJ$p7JIUvBE=^=!F}hI4UP-$6UkOTCmg4~T`j`QuS0vXZ^XD$-wuH{>#SSF zMTcF~F0~eO+fHjamw>f)O2-F_4`8SnetWV{#sNjg*#zu~#DHS|m<;^P^^Pu`j|G?& zx=MrAK!7c5b}~lEc*_h5RyoDBgTbz|p!H?Xb-I=k?7IFt0bANBlG$LW6GrHYo;%ru zTF1rEv;w0KvV9n;xC$&IU^nGyEJmQ4Hl3Kz>0kN+20KtF77g9dtaKly|Dw$_H2H+z zBPrsYmD^G0ntR!jNYG{PZvvHcXQS+=31zT{zW>rJHfL1lC1kXowsAmpf-RiIVp>Ip z*MEQ`UsHA>2VP(P$=O3+xGFAM(=8fHkySlGw14><;$Sf+x=Yt6yOuwoxgwBV2jZci z%soF}sm#NRp6C#9m&}hI$*QuO!YMwa&!=Utp9>IWkDZ)<0K3%xhPHF&c;; zincOFL4k9msHimLF>`w+B1ohup>JV(YS+@BF#3jqvZgfyr9xn9BH;dGR27no$|z0z z1!3pHfTK00EzJH^7(HDyh7JyGZy>EJ8)O0_g=d({5_z-)+NF*B@IkiKcfr{o zw7KUDTvP@aM6H&90?}z6>9j>AHG7e-cOjQE5mROtX!aE85<*5oIw)oe1)1$v6jNkH zfP$h71@2)E*TaB}`x{H}GfVcnpyht?(MhSIH$!N#tc?iAy537O`3Ba?-ZYWtm$lHB zGst^sT7+u*heQkl1kZvv9dT;V4sKF{rKL+2s;An&h4=~q7Gs*(1sP?ZOfcjFzq)5G zbS5gIG5yczxrCQac1T4UoZE${bs^?(cpc=UhGdJv-Qr;%4Wtt>0)Ay71h|}{gMZ%7 z5M;jPz{DBoD<4n;j}AsIt#?Gguk4llk|nx|6C|rZ;)g(9QouThywM+t7s&=}CY+Bh zJ4emH7Dg@5U>IU1bk=l&+T4_;gOpV%j6!gfd+!XcpqY!xpa^%3`5V;=*`Ddac;xo#NA&MFda&boCw~x=fZ-;IhWk~}< z5sKCtw>b*_xe=*P5>~A$fOA_zAW01sp={n*{|ye`sBF!htp^pOCLht*-r$d17b&V- zOOgGV2zTiXa5sVKC}iG*1lV=GS4@N;-22jSZRlr8aFn7M?2bW-x{%HWq`eE-D`e^x zjK#z9{`Ivq`AT8~N~=ImZmEzd)pK(RBlY)zYs_~d6xa-gkP9z}*t7m?tx`l;Y)hR} zl=2l9ABpVuf~*vXJ&3yP)-Q~*i_Zsz3!Q(K`zxa3WS);5G>}o{($u`n@oq7`anQUN zADOrj2eM85Pw4Ui6l;)in|nWi(2G;34D3CbdACRaj2#o%>-TaA@$l^~6R6`#Rz*AlC>Nw4|)v}vQV+~UMUCp zH{teM=mD&F)xEC`F~#Oow(eX(u$ca`mKsPgfX$&B$g8k9G`^y`PBFpbz`8MYo~7su zx$l6GxAcI&Q<#(ezb{=HKdMF8FZ$nJ&RKYuA4-;XME>SolNWuN@f`#>aV8y0+;JEv7^i$<;G1mDO+Agqd3Mta3|G?+CiQ89G-FdxU)gRUH z9CJ0?!<6HpoGZRW+YmQFN*}zHT9M>c^HMGOW8@0y!gC8ZzbQ>x-evg-)}@@Y%Pyh! z?R{ha(kV}F&JDv@;AGt_0pc}%oYXj2?vSTCVd1&`1IB8pV{>7$Te<6cYXxAz4oKV##tzLt zBi%Oc*6{Xt9t+D{h!8-w4WtQhUZUT^twE~wh!1;k*Ta`97+5_ zujpM&;;l=D#Ii$niO^SBGP|Hf%7*RN(>o|#3R$29k9(3bdhlDhA@v7(VMWHi+_@jP zx9^t_dv25(211v;S|&ZW=&tY+gzF;x{t##4zvpQ%uHW?Nvp-6Y+9uaxPI7PZ*BTx_ zIXa`v%pGk#WaWA+Y^4v5>)9%g`lT~KZ}Y4vcr22o^D;#@^LvG6&qQJMaVHgA{q}rF ztLB4@MY^q{_H5#ImZ0Z1@$^`Y!rlY-IwPGabxIWuAF)IzZ8zbl@3 zL%-H1{ULF%)$`zcgb(M2(Es2tZD@A%^*@|K!Ci;_*48*(iAxT_Z`GGt9!RKMK8TO8 z*j$67yEK+#zIWVRzu>_eXYqPf!>i*dDf|6qP3CgFusW8Gp6T@Q>JE|9^f!IzMVx5gu%>zJJHS&fN% z<4b*Irv&pK)#zq*NPxhW=TN}jIk{ixIA zRcU!v#8`)C97}p}GBe~n^K8f2d1s=ERcCcxDZWrKA1+y$lX(nDScm%d2aO-?+iyg> z^JY-?t;gLJUy9SAma436 zLxVD_LH(t&3Y|KY@~+vw*_%uL+eI*yzl|Nnl6*;8zFS_a?P-wmQ=@cH2%FLUr!$1} zZKd-=i4lDG;4+`mZ{U9SOu&iNpR)EP<401o-)x|b#?8nA*~QLs^APrxe&rZf`Atog zZhBRMY9oO!qX=9s2;$)YOi^!WK)c=drLVii-_0q88jYil>IrFkukRcpXco;rclT$8 z-N|~xcuN^cYu?1!V@l+`kImgUmw|oVtFijHSd+YbmZ{N-P1r=NENMLDQIr#i?~h}} zxbLu{rdI_K3|(d}g>KrNC5mger3@3T0+zH9@?g2?($VVmG6BZGU-I9 zqu0Y6?UX$IiUO91dNzSEw==)>%W}5ctU|hxXiqh-e)K|=V#LgH*YAxoDagSm<6j89 z8FMSxUYeT^-lTO-6G{=(VA|f9XtJRJgOI!od2eIH^OA`jt7}=Jpq$>w`;F5)1)Uc& zXv62mM>-c;lpk*Dm>h8X_r;*Tq>TB3Ecy23qkHbiRaNYCIy<{>CtQiY^MXISP@Gv` zoQ(H*&GZ!W6o+~)8lL~|R|zHL+DQG@^GkEGlVS06%$~Jg;k%f(Q?mGJ2g>Af8urn* zZS*Y}akZ7_^|%){OHrDb&9ecvgxe}{1#4yTPA^7M6xqqgUu+1xTZ$Cz zwe~jtVIo;OauWyNx7Q_ji&n4aScTHMW%NLs6BJgXJJD28BHEbQRC=|PqM_I3Ji3*N zucIGidd4^(UiDmZ8F##&_59!c*L*t99ylKPMEl&!Bw@5+9rsaZ{n(8}Sd5z(wOn+2L);3Z@Tm;%#Hl=(-)$fk54s^%a9`GpG*V$$CT3 z|7RV14UDPySrX8Wwv5doCxR|P`Pj8|Q}_;xY4Vf+EnjlG`Dp)4Abfm+h<(q^>l?WN z1M|jE9v;`R92Aqy% z7CKq4Ys~;X7T1XgTGsNgIV-|7(1%2dOzC<6X<;)$h%?%)&_N2m#?-G#!>`L_9UTCE zP&N)Z!WKnB#31`#P!~j7k5f?Qm(Ij;$8^4C15G31etB;62~h}8x(bTQ#nY$Z4d^C+ z9>A-TLS5ThivrWh0YkV5@OMk!!b~itopMGF8ngVQ3h+?}us(_W>$MO~6P=LtB4zZ<%&_+SSH>gDA15lvV0<%JzGl9P%pq?MvAV*y+ zF$xB{Or$e`F=C)1&El9cO=}l0xVz3 z`VnQf_qUJ9qWS{-OpDY4wd14`_X+HMFo>mPP^c2IBc-{3^G3y4RC+{_fP!M=h)K` zeL0xWmBK}A&ae*HvIO$YfG(=JrF|;Whgx;!>@qWL5jAxjn3_*PO__X;l7CEBYr=FyxO?G1ki@^=Q)dv=OdE9>R9rFbJ+L@fUiHs_;Wc6_zIF1SGRQ}y}(pR4H2!`+Xu|C5#wBRZuCz(*l-r*W*)O6D&XMH4% zSK>9rT`s}lTfIoj-;lD37zNQ4IQG6;dwcr^Qr_N`_dNntd zlD5?qRO=iWikLdW4hN;7swA%!*Ye=TP(i{s^0MYLyS&&s-2<0rvAvn z3q4XMA+>wNlD^~_o%L+zhMIqoOeWVhFv=)unFRS~;_ok3>)nDGrT$@d=xPbq%wQ61S4Y7^GR-F=!Rk#$}jA(g2^CCz} z)Kq<9BztViQuq51_Eyh0iA}=jPRaU_jm(-zN$7ysW+HmiHEh4K1_K+I4%zA0O_%;y z2#<5^`2{9w4-0L%$IC8f^zb1(BR=yuuKTG)j84r3o9)2{J^eSnb?H)k%i2M+XT0^e zlxZc6k93qiK^l%h*|?_M*eGq_N-n!iigoYWXIV8aEw1daWp+JKMiCM?Fe9h6;dxvr z^s$7|^z(l2oo9YzJluTkrl9#gxRxcHVK#D%l&-?Sj;0AjUtRVVR$jQYt%C4Yu9m7! z?xGKy%=)Td7|u_=;L7;^Sh1HDco2Ztn^c@iBeyKcE>tI1Wu=zl;=yaawu5U4!WAG)7$_pRhM)*8U6!CD=+UYw?pZ%rtc;jyZ z>z`z((GD`aB*_PFcpvH9%zJFGn@&?roK0MN3~NL7qoXB%+_b=LmOtKHX=s&bT}U@| zmDAU)AlmkN@9JciZM6l9&Y!WjfQ53-}WIqWoAo#!@VstUEJ+t9V70>qm7AX zu7i(x4ozJT72=MAk%(WpmrbO4k-*W#i&?Gjpkn@<%5vK9QkOsPZfxN4TiinL$1X9x zttN-^*=SQ}u9%m%_QgZX=UWdv>sz;t`q7l9zmelS=@Qyb7#`e6U+ZYvhZTA6xp>PAAx`65Q&!RD)AcJ0&b3Zswp7injc;J4Ki)#Hi>sb)wW zu8}oS2_LS;C6T_UO0D%ZtxDZB9GnCWa#ZWf8{LN&zMC-d`2t+DbownWOlwgvHVU6g_m9au5@ zXJu-w;AlD1L`?TmUq7TS>s&deZmGpM=v=Mee~MD(++#w@5bS6#bFd)KO*HtrrY}Tj z=G!zXDU81IVuhQ!Kkp&qFBPzG%Zsc4o$*8W3%OlLJt_P9H|BzU!~-!kKqvRl7tKXlOcpP1wfAzvBpor2O$e`$P|~8=r>b~#gI+?%jC-fvY z=#sKS*OHdraA44Im`mIS$qnv#dX$LeS`L*nC_k2DAqlNe_}=d`mv!(mm7F%`y7 zoR3Uh7cn^LV60W;<+zve40Ij35tXpBspf(d?8bc3;J^en$o5Sjz;a3ToiQK8P`on3 z^c|K0$*BVX1y}J{^@7sJRU_pvsT&3DjJ7snLGB7GyAVhsC)Vhv%?LAV^i7q1Kp%P+ zq`^44lX3J6j7+paccG`_VUaljclmI;Y6uc4w zJo6L6J7CbooMH_HHyF_LllKvV*zRvE7{p4(pU2ewNm+&@b#HYD$PM~itH7`@)LW{x z?Uj^t1du-f&ci(iU^2mfV%F%NNkinAA|W4Gb%AiO>%C~ro*WWHv4v3-{kolJ5#RzB zG6!WVpaqUK30eScG`hicfI!^aZB$@#NG1Jm8M{%?Vs|il&EZ)+>s~Hsv9>1F&3`3Z z15RQtt}jMzRi`om+-aXMC{lvHMwCW!fU+5*XAlMo4LDUZ)_q)CLN&)V$wA@EC=~e# zB3XO5P&a(q;!?qYF-j@&aqT3Z0m$Q^O2#6PbzKwr6rGj<<4~jm*ciu&N9~AK zf>~SG2+E~g2|W94#y6zQZ%DZT)jAmDv>Kxq(8=?x4hE(KQFfsh4VK>mr)$-+jxc@) z{mO`F%v+{zB0ooYz8yv)14WPQE@LQHfFJZrX&@T43cL<{Mn|DuhH@Fei%!V&$y1km;V<9J7y9n~yYX?p z(BIY@QJTFb<7e-~ZHsOF_ei4uNyqy6b=V$xu0u3#>DXO|Z8+{7-b~rcS!+ z+;#3ccb&V=SF96fR&~;Gziy{jh zjicgyhd#)|H*$UY%?J`L?hx^FE5ii5YiSKYL2BFJ`-Ouup588z<;XIIC9szoNG(?U3=dM}mJ# z`*y(p6G4{vlf==C?Gk?i706hB9qlz)|H+C!Sy`u%upVt)_JjBG{LEKBHZS@#iVRy#kTFb% zrx@oMO=NyZ;+WNdKLdfhR+RA|X~)_jFG5A^Tg(p?v5)T!WwEat50BYXo{Ljd%=>cRk3x733>~n%F`#+fjXKAwk(~M}yHpISZ*vVx7 zHwVL>>;ps|`Vt4pSbIG;lmG&K^m}M>Bm2K;*q;pc@k9G3`@iV}&&&RAI{d?6->VKM zotXEI{6jb3qR(TTmwPuqS7U$p`%g2E`q$%kAHF@_y&sQ1ynA~4?e|ZQPvyg#@pS7n zmM>8&zjPWq2jDU@X$=5!tk<2GnsD$JPE5_`PE5r$$i8r5ns4iHeIaWVo%CEM@LlJw zbJux=j=ibl?>cv#UrXmw{lB~~+$IK}(^)$hcCOJcS@@Y492FTOh`}hR@IVY^f^{MW z{Ze#q#GqxZ@FioY1sMi5@S2e^ff$tP$4(4#?|CN%DaF1MgH!_T#2_&$ASDJ78OxOz z9JCXIsdXX-!RY%)rXDV1xDkUnEW8r~pELoH6xe%YL?8zKvgdBZz!e|&gvHI2d?yC3 zpnVVnN9YfT!OUOtP7Jv9x)TE~$dEw{*n7JZ1MYZn5QCW@`kffeis9Ia0lOM@Vld^t z69Y!&q{M&?GC&Xmd$!IbSz4CZ=6!s}32GUtsL%tFkijTlUOU}QoJrd)U<2D5^}_zN+ZzjwP4gZVxo zZ^U4>Z}LVAE`{RQh{5Ik$3_fhg{2c!#C~GG5re5e@bLkR?9{#)IN0lQ*%V!$Dqoft4<{el=we?4|$ zz#0AvV!+2yc4EL4;{js8&7Ft=XN)h10mtljV!($!FT}tZB?v+c+%&Kg0|&oQV!%i2 zJ27zAftA4L3x zRbmh#orr;J$B7sah^G<*hJPtBASD7I1_%5d#9&TyIT3?__@l(2Q#=s^C0a@h8i3kvvW(>a* z1EcX9#J~*nN8@-yyj5aQu>XS?m=;gOz|@*g#Gq&e4`NW!&nq!dA^~DhMm-UOiuI<% zAY(l!F-XRqh(XlxP-LIWi5TR|I;5MPiiRG+g%`0@O zJL^z>g$_N_39rz3);IFMm(DL{dt!rAYQfrBKS`!3TrFMl<}=%yj#ziLX9tRXusz%2 zv9mqfO5fSuG*Nf9XDe!Fq}reKCAYT)wHw%;t(-^V0&zEdjo8b!7P;RF`;$<+vA{zky6ZXc787{xZrdd zY>zuw4%ptT?T-`Na};*A=OS1D*q+Pv*x8=zF7Is5EBkbM=}K9e46JVl?`&_DB($@= zpto7T_JW??g6&0z+6!zi7QJmFHC*CQ_fjf(y=^3nDOJ>hV0)QI+u2^O*6eIAH=lO4 zS3GJxu)V_W#P&)7>j2UzCG59>Ov$hZhEw!*3D{l<@CUHH63{-_-ki=8+sg;-w}DLV z<=EL?j>`Ik?PZQ(XM5RF*x6nh)ONO)V)Z-QOBSd9V0%f6c(A<~ak~%06v3dB?S-zt z3)>4xS*0+xKv>^kd%og22-u$Ye7X(f3F`yv#8tgLg=r691z>xQaT`O~UI)ukwwH0+ zK-pfzcq!YnI2%y5r~V7JXL}ku+p`V-Cnwn6l?{U#&G?*mwr7Egl=(-R0@$yzy>O%x+w;omkedY^Xs|uKji+qSYqAKo=M}z!?Rn$} z%J#6&Uz1GrUuAnv`=hsD)A=@#!&%hza4ny21Gx)puCC|B_I~;|CAggbnLz*mUVx&| literal 0 HcmV?d00001 diff --git a/pylabrobot/bmg_labtech/clariostar/driver/master.py b/pylabrobot/bmg_labtech/clariostar/driver/master.py index 13dd70a0757..6665b05e684 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/master.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/master.py @@ -112,8 +112,10 @@ class CommandFamily: RUN = 0x04 REQUEST = 0x05 TEMPERATURE_CONTROLLER = 0x06 + FOCUS_WELL = 0x09 STOP = 0x0B SET_PARAMETER = 0x0E + FILTER_SCAN = 0x24 STATUS = 0x80 class Command: @@ -126,17 +128,23 @@ class Command: DRAWER_OPEN = 0x01 # REQUEST DATA = 0x02 + FOCUS_RESULT = 0x05 PARAMETERS = 0x07 + SPECTRAL_DATA = 0x11 + FILTER_RESULT = 0x1B # SET_PARAMETER; seen only writing the two-byte test-run counter SET_PARAMETER_WORD = 0x0B - # Families whose payload is the family byte plus parameters, without a command byte. + # Families whose payload is the family byte plus parameters, without a command byte; FILTER_SCAN's + # first parameter is a mode bit field. _NO_COMMAND_FAMILIES = frozenset( { CommandFamily.RUN, CommandFamily.STOP, CommandFamily.STATUS, CommandFamily.TEMPERATURE_CONTROLLER, + CommandFamily.FOCUS_WELL, + CommandFamily.FILTER_SCAN, } ) diff --git a/pylabrobot/lib/fluorescence/channels.py b/pylabrobot/lib/fluorescence/channels.py index f587a8a1be0..35192e94ffa 100644 --- a/pylabrobot/lib/fluorescence/channels.py +++ b/pylabrobot/lib/fluorescence/channels.py @@ -5,7 +5,7 @@ """ from dataclasses import dataclass -from typing import List, Optional +from typing import List, Optional, Union from pylabrobot.lib.optics.filters import DichroicFilter, OpticalFilter @@ -14,7 +14,7 @@ def _check_excitation_or_emission( excitation_or_emission: str, wavelength: Optional[float], bandwidth: Optional[float], - optical_filter: Optional[OpticalFilter], + optical_filter: Optional[Union[OpticalFilter, int, str]], ) -> List[str]: """The violations of excitation or emission. @@ -34,7 +34,7 @@ def _check_excitation_or_emission( def _check_dichroic( - dichroic_filter: Optional[DichroicFilter], split_wavelength: Optional[float] + dichroic_filter: Optional[Union[DichroicFilter, int, str]], split_wavelength: Optional[float] ) -> List[str]: """The violations of the dichroic: a fixed dichroic with a tunable split, a split not positive.""" errors = [] @@ -60,23 +60,26 @@ class FluorescenceChannel: Attributes: excitation_wavelength: nm through a monochromator or a tunable source; None with a filter. excitation_bandwidth: nm about `excitation_wavelength`; None with a filter or the default. - excitation_filter: the excitation filter instead of a wavelength. + excitation_filter: the excitation filter instead of a wavelength: a filter, or its slot or name + on the device's slides. dichroic_split_wavelength: nm where a tunable dichroic splits excitation from emission; None otherwise. - dichroic_filter: a fixed dichroic instead of a split. + dichroic_filter: a fixed dichroic instead of a split: a filter, or its slot or name on the + device's slides. emission_wavelength: nm through a monochromator; None with a filter. emission_bandwidth: nm about `emission_wavelength`; None with a filter or the default. - emission_filter: the emission filter instead of a wavelength. + emission_filter: the emission filter instead of a wavelength: a filter, or its slot or name on + the device's slides. """ excitation_wavelength: Optional[float] = None excitation_bandwidth: Optional[float] = None - excitation_filter: Optional[OpticalFilter] = None + excitation_filter: Optional[Union[OpticalFilter, int, str]] = None dichroic_split_wavelength: Optional[float] = None - dichroic_filter: Optional[DichroicFilter] = None + dichroic_filter: Optional[Union[DichroicFilter, int, str]] = None emission_wavelength: Optional[float] = None emission_bandwidth: Optional[float] = None - emission_filter: Optional[OpticalFilter] = None + emission_filter: Optional[Union[OpticalFilter, int, str]] = None def __post_init__(self) -> None: """Raise listing every component of the wrong combination or sign. diff --git a/pylabrobot/lib/optics/holders.py b/pylabrobot/lib/optics/holders.py index 9ebfb884a8d..a95e954c24c 100644 --- a/pylabrobot/lib/optics/holders.py +++ b/pylabrobot/lib/optics/holders.py @@ -9,7 +9,7 @@ F = TypeVar("F", bound=_FilterBase) -class _FilterSlideBase(Generic[F]): +class FilterSlide(Generic[F]): """The filters a device holds on one side, by slot. Attributes: @@ -74,21 +74,21 @@ def __repr__(self) -> str: return f"{type(self).__name__}({self.maximum_slots} slots, {{{', '.join(held)}}})" -class ExcitationFilterSlide(_FilterSlideBase[OpticalFilter]): +class ExcitationFilterSlide(FilterSlide[OpticalFilter]): """The excitation filters a device holds, by slot.""" _CATEGORY = "excitation" _FILTER_CLASS = OpticalFilter -class EmissionFilterSlide(_FilterSlideBase[OpticalFilter]): +class EmissionFilterSlide(FilterSlide[OpticalFilter]): """The emission filters a device holds, by slot.""" _CATEGORY = "emission" _FILTER_CLASS = OpticalFilter -class DichroicFilterSlide(_FilterSlideBase[DichroicFilter]): +class DichroicFilterSlide(FilterSlide[DichroicFilter]): """The dichroic filters a device holds, by slot.""" _CATEGORY = "dichroic"