diff --git a/src/probeinterface/__init__.py b/src/probeinterface/__init__.py index 45e102bf..25b48aa2 100644 --- a/src/probeinterface/__init__.py +++ b/src/probeinterface/__init__.py @@ -14,9 +14,6 @@ write_csv, read_BIDS_probe, write_BIDS_probe, - read_spikegadgets, - read_spikegadgets_neuropixels, - has_spikegadgets_neuropixels_probes, read_mearec, read_nwb, read_maxwell, @@ -33,6 +30,10 @@ read_openephys_neuropixels, has_neuropixels_probes, get_saved_channel_indices_from_openephys_settings, + read_spikegadgets, + read_spikegadgets_neuropixels, + has_spikegadgets_neuropixels_probes, + parse_spikegadgets_header, ) from .utils import combine_probes from .generator import ( diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 90849e81..bd3ba609 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -11,17 +11,13 @@ from pathlib import Path import re -import warnings import json from collections import OrderedDict from packaging.version import parse import numpy as np -from xml.etree import ElementTree - from . import __version__ from .probe import Probe from .probegroup import ProbeGroup -from .neuropixels_tools import build_neuropixels_probe, _annotate_probe_with_adc_sampling_info from .utils import import_safely @@ -733,193 +729,6 @@ def write_csv(file, probe): raise NotImplementedError -def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> ProbeGroup: - """ - Find active channels of the given Neuropixels probe from a SpikeGadgets .rec file. - SpikeGadgets headstages support up to three Neuropixels 1.0 probes (as of March 28, 2024), - and information for all probes will be returned in a ProbeGroup object. - - This function only supports Neuropixels probes recorded with SpikeGadgets - headstages (``HardwareConfiguration`` entries with ``name == "NeuroPixels1"``). - It does not handle tetrodes or other probe types that SpikeGadgets can - record. Use :func:`has_spikegadgets_neuropixels_probes` to check whether a - ``.rec`` file contains Neuropixels probe geometry before calling this reader. - - Parameters - ---------- - file : Path or str - The .rec file path - - Returns - ------- - probe_group : ProbeGroup object - - """ - # The SpikeGadgets .rec XML does not include a probe part number. The NP1.0 - # catalogue variants (NP1000, NP1001, PRB_1_2_0480_2, PRB_1_4_0480_1, - # PRB_1_4_0480_1_C) share identical 2D geometry in the probeinterface - # catalogue (contact positions, pitch, stagger, shank width), differing only - # in metadata that probeinterface does not consume (ADC resolution, databus - # phase, gain, on-shank reference, shank thickness). So hardcoding NP1000 - # produces correct geometry; `model_name` and `description` are cleared on - # the sliced probe to avoid claiming a specific variant. - PART_NUMBER = "NP1000" - - header_txt = parse_spikegadgets_header(file) - root = ElementTree.fromstring(header_txt) - hconf = root.find("HardwareConfiguration") - sconf = root.find("SpikeConfiguration") - - probe_configs = [d for d in hconf if d.attrib.get("name") == "NeuroPixels1"] - n_probes = len(probe_configs) - - if n_probes == 0: - if raise_error: - raise Exception("No Neuropixels 1.0 probes found") - return None - - # NeuroPixels1 SourceOptions blocks carry the per-probe AP/LF gain settings. - # They appear in the same order as the SpikeNTrode probe digits (1, 2, 3). - source_options_blocks = [s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == "NeuroPixels1"] - - probe_group = ProbeGroup() - - for curr_probe in range(1, n_probes + 1): - # SpikeNTrode elements are the authoritative list of recorded electrodes. - # Each id is "<1-based electrode number>" for up to 960 - # electrodes on NP1.0; the catalogue uses 0-based indices, so - # catalogue_index = electrode_number - 1. The probe number is assumed - # to be a single digit (1, 2, or 3), matching the documented - # SpikeGadgets limit of three simultaneous Neuropixels probes. - electrode_to_hwchan = {} - for ntrode in sconf: - electrode_id = ntrode.attrib["id"] - if int(electrode_id[0]) == curr_probe: - catalogue_index = int(electrode_id[1:]) - 1 - hw_chan = int(ntrode[0].attrib["hwChan"]) - electrode_to_hwchan[catalogue_index] = hw_chan - - active_indices = np.array(sorted(electrode_to_hwchan.keys())) - - full_probe = build_neuropixels_probe(PART_NUMBER) - probe = full_probe.get_slice(active_indices) - - # Clear part-number-specific metadata since we don't know the actual part number. - probe.model_name = "" - probe.description = "" - - device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices]) - probe.set_device_channel_indices(device_channels) - - # Per-contact ADC group and sample order from the catalogue MUX table plus - # the hwChan mapping (which is the readout-channel index for each contact). - adc_sampling_table = probe.annotations.get("adc_sampling_table") - _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) - - # NP1.0 gain is programmable. Read APGainMode and LFPGainMode from the - # SourceOptions block matching this probe (blocks appear in probe order). - if "ap_gain" not in probe.annotations and curr_probe - 1 < len(source_options_blocks): - custom_options = { - opt.attrib["name"]: opt.attrib["data"].strip() - for opt in source_options_blocks[curr_probe - 1].findall("CustomOption") - } - ap_gain_str = custom_options.get("APGainMode") - if ap_gain_str: - probe.annotate(ap_gain=float(ap_gain_str)) - if probe.annotations.get("lf_sample_frequency_hz", 0) > 0: - lf_gain_str = custom_options.get("LFPGainMode") - if lf_gain_str: - probe.annotate(lf_gain=float(lf_gain_str)) - - # Shift multiple probes so they don't overlap when plotted - probe.move([250 * (curr_probe - 1), 0]) - - probe_group.add_probe(probe) - - return probe_group - - -def read_spikegadgets(*args, **kwargs) -> ProbeGroup: - """ - Deprecated alias for :func:`read_spikegadgets_neuropixels`. - - The name ``read_spikegadgets`` is misleading because the function only reads - Neuropixels probe geometry, not arbitrary SpikeGadgets ``.rec`` recordings. - Use :func:`read_spikegadgets_neuropixels` instead, and - :func:`has_spikegadgets_neuropixels_probes` to check whether a ``.rec`` file - has Neuropixels geometry before calling it. - """ - warnings.warn( - "read_spikegadgets is deprecated and will be removed in a future release. " - "Use read_spikegadgets_neuropixels instead.", - category=DeprecationWarning, - stacklevel=2, - ) - return read_spikegadgets_neuropixels(*args, **kwargs) - - -def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: - """ - Return True if the SpikeGadgets ``.rec`` file describes at least one - Neuropixels probe. - - Detection scans the ``HardwareConfiguration`` block of the ``.rec`` XML - header for ``Device`` entries whose ``name`` attribute matches a known - Neuropixels source name (currently ``"NeuroPixels1"``). The presence of - any such entry is the ground-truth signal that the file contains - Neuropixels probe geometry, independent of what other hardware the - headstage is also streaming. - - Intended use: callers that route heterogeneous SpikeGadgets recordings - (mixing tetrodes, Neuropixels, etc.) can gate the call to - :func:`read_spikegadgets_neuropixels` on this helper and skip probe - attachment for non-Neuropixels recordings. - - Parameters - ---------- - file : str or Path - Path to the SpikeGadgets ``.rec`` file. - - Returns - ------- - bool - """ - try: - header_txt = parse_spikegadgets_header(file) - root = ElementTree.fromstring(header_txt) - except Exception: - return False - - hconf = root.find("HardwareConfiguration") - if hconf is None: - return False - - for device in hconf: - if device.attrib.get("name") == "NeuroPixels1": - return True - return False - - -def parse_spikegadgets_header(file: str | Path) -> str: - """ - Parse file (SpikeGadgets .rec format) into a string until "", - which is the last tag of the header, after which the binary data begins. - """ - header_size = None - with open(file, mode="rb") as f: - while True: - line = f.readline() - if b"" in line: - header_size = f.tell() - break - - if header_size is None: - ValueError("SpikeGadgets: the xml header does not contain ''") - - f.seek(0) - return f.read(header_size).decode("utf8") - - def read_mearec(file: str | Path) -> Probe: """ Read probe position, and contact shape from a MEArec file. diff --git a/src/probeinterface/neuropixels_tools.py b/src/probeinterface/neuropixels_tools.py index 794677c7..ef09d605 100644 --- a/src/probeinterface/neuropixels_tools.py +++ b/src/probeinterface/neuropixels_tools.py @@ -12,8 +12,10 @@ from packaging.version import parse import json import numpy as np +from xml.etree import ElementTree from .probe import Probe +from .probegroup import ProbeGroup from .utils import import_safely global _np_probe_features @@ -1759,3 +1761,340 @@ def get_saved_channel_indices_from_openephys_settings(settings_file: str | Path, if recording_state not in ("ALL", "NONE"): chans_saved = np.array([chan for chan, r in enumerate(recording_state) if int(r) == 1]) return chans_saved + + +###################### +# SpikeGadgets zone # +###################### + + +def _spikegadgets_channel_index_np2_4shank(channel_index: int) -> int: + """Remap NP2.0 4-shank ``channelsOn`` bit position to catalogue index. + + Trodes writes ``channelsOn`` row-major across all four shanks (eight + contacts per row, two columns per shank), with the column-within-row + direction reversed relative to ``probeColumn`` (high ``channel_index`` -> low + ``probeColumn``; see Trodes `configuration.cpp:5374-5421` and the + `probeColumn` annotations in the .rec ``SpikeChannel`` elements). The + catalogue (``NP2014``) is shank-major instead (s0e0..s0e1279, + s1e0..s1e1279, ...), so channel_index needs remapping. Verified empirically + against the SpikeGadgets-provided NP2.0 4-shank fixture: channel_index 1671 with + ``probeColumn="0"`` maps to ``s0e416``, channel_index 1664 with ``probeColumn="7"`` + maps to ``s3e417``, and the .rec ``coord_ml``/``coord_dv`` values for those + SpikeChannel entries match the catalogue positions up to a single stereotactic + offset (these XML coords are not consumed by the reader, only used by the + test in `tests/test_io/test_spikegadgets.py` as an independent cross-check). + Independently confirmed in May 2026 by Mattias Karlsson (SpikeGadgets / + Trodes author) on PR #441: "the 2.0 four-shank probe has two columns per + shank... the first electrode on the probe (starts with 1) is in the lower + right, and number 10008 is on the lower left. Then, 10009 is the second + row on the right, and so on", which is exactly the (row, col_global=7-x) + layout this function encodes. + """ + CONTACTS_PER_ROW = 8 # 2 columns per shank * 4 shanks + COLS_PER_SHANK = 2 + CONTACTS_PER_SHANK = 1280 + + row = channel_index // CONTACTS_PER_ROW + col_global = (CONTACTS_PER_ROW - 1) - (channel_index % CONTACTS_PER_ROW) + shank = col_global // COLS_PER_SHANK + col_on_shank = col_global % COLS_PER_SHANK + return shank * CONTACTS_PER_SHANK + row * COLS_PER_SHANK + col_on_shank + + +def _spikegadgets_channel_index_np2_1shank(channel_index: int) -> int: + """Remap NP2.0 single-shank ``channelsOn`` bit position to catalogue index. + + Same row-major-within-probe layout as NP2.0 4-shank (Trodes + `configuration.cpp:5279-5290`) but with only one shank and two + columns per row, so two contacts per row. The within-row direction is + reversed relative to the catalogue (extrapolated from NP2.0 4-shank + where this was empirically verified): channel_index 0 -> right column, channel_index 1 + -> left column, channel_index 2 -> next row right, etc. The catalogue + (``NP2000``) lays out contacts with left column first (idx 0 = left, + idx 1 = right per row), so the remap pairs are swapped: + catalogue_idx = row * 2 + (1 - channel_index % 2). + + Unverified against a real fixture; will be revisited when a NP2.0 + single-shank .rec from a Bennu rig becomes available. + """ + COLS_PER_SHANK = 2 + + row = channel_index // COLS_PER_SHANK + col_on_shank = (COLS_PER_SHANK - 1) - (channel_index % COLS_PER_SHANK) + return row * COLS_PER_SHANK + col_on_shank + + +def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> ProbeGroup: + """ + Find active channels of the given Neuropixels probe from a SpikeGadgets .rec file. + SpikeGadgets headstages support up to three Neuropixels probes simultaneously, + and information for all probes will be returned in a ProbeGroup object. + + Supported Neuropixels variants: NP1.0 standard (``device="neuropixels1"``, + older recordings without ``deviceSubType`` are treated as standard), + NP2.0 single-shank (``device="neuropixels2" deviceSubType="1_SHANK"``), + and NP2.0 4-shank (``device="neuropixels2" deviceSubType="4_SHANK"``). + The single-shank channel_index remap is extrapolated from the 4-shank pattern and + has not been verified against a real fixture yet. Other Neuropixels variants + Trodes can describe (NP1.0 HD, NP1.0 NHP short/medium/long, NRIC) raise + ``ValueError`` for now; non-Neuropixels probes (tetrodes etc.) are not + handled at all. Use :func:`has_spikegadgets_neuropixels_probes` to check + whether a ``.rec`` file contains Neuropixels probe geometry before calling + this reader. + + Parameters + ---------- + file : Path or str + The .rec file path + + Returns + ------- + probe_group : ProbeGroup object + + """ + # Dispatch keyed by SpikeConfiguration (device, deviceSubType) attributes + # (see Trodes `configuration.cpp:2495-2520` and `5246-5291`). Each entry + # gives the HardwareConfiguration `Device` name to filter on, the catalogue + # part number to build the full probe from, the per-probe horizontal shift + # (um) used when plotting multi-probe ProbeGroups, and (optionally) a + # function remapping Trodes' ``channelsOn`` bit position (channel_index, equal to + # ``electrode_id[1:] - 1`` in the .rec XML) to a probeinterface catalogue + # contact index. The remap is None when Trodes' ordering already matches + # the catalogue's (NP1.0 standard). + # + # All NP1.0 staggered catalogue variants (NP1000, NP1001, NP1010-NP1014, + # PRB_1_2_0480_2, PRB_1_4_0480_1, PRB_1_4_0480_1_C) share identical 2D + # geometry, so NP1000 is the canonical pick. All NP2.0 4-shank catalogue + # variants (NP2010, NP2013, NP2014, NP2020, NP2021) share identical 2D + # geometry, so NP2014 is the canonical pick. model_name and description + # are cleared on the sliced probe in both cases because the XML does not + # carry a part-number field. + spikegadgets_neuropixels_formats = { + ("neuropixels1", "10"): { + "hardware_device_name": "NeuroPixels1", + "part_number": "NP1000", + "multi_probe_plot_offset_um": 250.0, + "channel_index_to_catalogue_index": None, + }, + ("neuropixels2", "1_SHANK"): { + "hardware_device_name": "NeuroPixels2", + "part_number": "NP2000", + "multi_probe_plot_offset_um": 250.0, + "channel_index_to_catalogue_index": _spikegadgets_channel_index_np2_1shank, + }, + ("neuropixels2", "4_SHANK"): { + "hardware_device_name": "NeuroPixels2", + "part_number": "NP2014", + "multi_probe_plot_offset_um": 1000.0, + "channel_index_to_catalogue_index": _spikegadgets_channel_index_np2_4shank, + }, + } + + header_txt = parse_spikegadgets_header(file) + root = ElementTree.fromstring(header_txt) + hconf = root.find("HardwareConfiguration") + sconf = root.find("SpikeConfiguration") + + # Older NP1.0 recordings predate the device/deviceSubType attributes, so + # missing values fall back to NP1.0 standard. + sconf_device = (sconf.attrib.get("device", "") if sconf is not None else "").lower() or "neuropixels1" + sconf_subtype = sconf.attrib.get("deviceSubType", "") if sconf is not None else "" + if sconf_device == "neuropixels1" and not sconf_subtype: + sconf_subtype = "10" + dispatch_key = (sconf_device, sconf_subtype) + if dispatch_key not in spikegadgets_neuropixels_formats: + raise ValueError( + f"Unsupported SpikeGadgets Neuropixels variant device={sconf_device!r} " + f"deviceSubType={sconf_subtype!r}; supported: " + f"{sorted(spikegadgets_neuropixels_formats)}" + ) + fmt = spikegadgets_neuropixels_formats[dispatch_key] + + probe_configs = [d for d in hconf if d.attrib.get("name") == fmt["hardware_device_name"]] + n_probes = len(probe_configs) + + if n_probes == 0: + if raise_error: + raise Exception(f"No {fmt['hardware_device_name']} probes found") + return None + + # SourceOptions blocks carry the per-probe AP/LF gain settings. They appear + # in the same order as the SpikeNTrode probe digits (1, 2, 3). + source_options_blocks = [ + s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == fmt["hardware_device_name"] + ] + + probe_group = ProbeGroup() + + for curr_probe in range(1, n_probes + 1): + # SpikeNTrode elements are the authoritative list of recorded electrodes. + # Each id is "<1-based electrode number>"; the leading digit + # identifies the probe (1, 2, or 3, matching the documented SpikeGadgets + # limit of three simultaneous Neuropixels probes) and the remainder is + # the 1-based electrode number on that probe (channel_index = electrode - 1). + # NP1.0 standard uses maxPadsPerProbe = 1000 (ids are 4 chars wide, e.g. + # "1384"); NP2.0 uses maxPadsPerProbe = 10000 (ids are 5 chars wide, e.g. + # "11672"). Slicing by [1:] handles both because the probe digit is + # always one char. The format's channel_index_to_catalogue_index function + # then remaps Trodes' channelsOn bit position to the catalogue's contact + # order; it is None when no remap is needed (NP1.0, where the catalogue + # happens to be in Trodes' bit order already). + electrode_to_hwchan = {} + electrode_to_stereotactic = {} + for ntrode in sconf: + electrode_id = ntrode.attrib["id"] + if int(electrode_id[0]) == curr_probe: + channel_index = int(electrode_id[1:]) - 1 + catalogue_index = ( + channel_index + if fmt["channel_index_to_catalogue_index"] is None + else fmt["channel_index_to_catalogue_index"](channel_index) + ) + spike_channel = ntrode[0] + electrode_to_hwchan[catalogue_index] = int(spike_channel.attrib["hwChan"]) + electrode_to_stereotactic[catalogue_index] = ( + float(spike_channel.attrib["coord_ml"]), + float(spike_channel.attrib["coord_dv"]), + float(spike_channel.attrib["coord_ap"]), + ) + + active_indices = np.array(sorted(electrode_to_hwchan.keys())) + + full_probe = build_neuropixels_probe(fmt["part_number"]) + probe = full_probe.get_slice(active_indices) + + # Clear part-number-specific metadata since the .rec XML does not carry + # a part number; the catalogue pick is a geometry-equivalence stand-in + # rather than a fact read from the file. + probe.model_name = "" + probe.description = "" + probe.annotations.pop("part_number", None) + + device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices]) + probe.set_device_channel_indices(device_channels) + + # Stereotactic coordinates from the .rec SpikeChannel attributes + # (workspace probe origin + on-probe offset, in micrometres; see Trodes + # `configuration.cpp:5443-5445`). These are recording-specific surgical + # metadata, distinct from `contact_positions` which carries the pure + # on-probe catalogue geometry. We attach them as per-contact annotations + # so downstream code that wants stereotactic locations (e.g. histology + # registration) can read them without re-parsing the XML. + stereotactic = np.array([electrode_to_stereotactic[idx] for idx in active_indices]) + probe.annotate_contacts( + stereotactic_ml=stereotactic[:, 0], + stereotactic_dv=stereotactic[:, 1], + stereotactic_ap=stereotactic[:, 2], + ) + + # Per-contact ADC group and sample order from the catalogue MUX table plus + # the hwChan mapping (which is the readout-channel index for each contact). + adc_sampling_table = probe.annotations.get("adc_sampling_table") + if adc_sampling_table is not None: + _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) + + # Neuropixels gain is programmable. Read APGainMode and LFPGainMode from + # the SourceOptions block matching this probe (blocks appear in probe order). + if "ap_gain" not in probe.annotations and curr_probe - 1 < len(source_options_blocks): + custom_options = { + opt.attrib["name"]: opt.attrib["data"].strip() + for opt in source_options_blocks[curr_probe - 1].findall("CustomOption") + } + ap_gain_str = custom_options.get("APGainMode") + if ap_gain_str: + probe.annotate(ap_gain=float(ap_gain_str)) + if probe.annotations.get("lf_sample_frequency_hz", 0) > 0: + lf_gain_str = custom_options.get("LFPGainMode") + if lf_gain_str: + probe.annotate(lf_gain=float(lf_gain_str)) + + # Shift multiple probes so they don't overlap when plotted + probe.move([fmt["multi_probe_plot_offset_um"] * (curr_probe - 1), 0]) + + probe_group.add_probe(probe) + + return probe_group + + +def read_spikegadgets(*args, **kwargs) -> ProbeGroup: + """ + Deprecated alias for :func:`read_spikegadgets_neuropixels`. + + The name ``read_spikegadgets`` is misleading because the function only reads + Neuropixels probe geometry, not arbitrary SpikeGadgets ``.rec`` recordings. + Use :func:`read_spikegadgets_neuropixels` instead, and + :func:`has_spikegadgets_neuropixels_probes` to check whether a ``.rec`` file + has Neuropixels geometry before calling it. + """ + warnings.warn( + "read_spikegadgets is deprecated and will be removed in a future release. " + "Use read_spikegadgets_neuropixels instead.", + category=DeprecationWarning, + stacklevel=2, + ) + return read_spikegadgets_neuropixels(*args, **kwargs) + + +def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: + """ + Return True if the SpikeGadgets ``.rec`` file describes at least one + Neuropixels probe. + + Detection scans the ``HardwareConfiguration`` block of the ``.rec`` XML + header for ``Device`` entries whose ``name`` attribute matches a known + Neuropixels source name (``"NeuroPixels1"`` or ``"NeuroPixels2"``). The + presence of any such entry is the ground-truth signal that the file + contains Neuropixels probe geometry, independent of what other hardware + the headstage is also streaming. + + Intended use: callers that route heterogeneous SpikeGadgets recordings + (mixing tetrodes, Neuropixels, etc.) can gate the call to + :func:`read_spikegadgets_neuropixels` on this helper and skip probe + attachment for non-Neuropixels recordings. + + Parameters + ---------- + file : str or Path + Path to the SpikeGadgets ``.rec`` file. + + Returns + ------- + bool + """ + neuropixels_source_names = {"NeuroPixels1", "NeuroPixels2"} + + try: + header_txt = parse_spikegadgets_header(file) + root = ElementTree.fromstring(header_txt) + except Exception: + return False + + hconf = root.find("HardwareConfiguration") + if hconf is None: + return False + + for device in hconf: + if device.attrib.get("name") in neuropixels_source_names: + return True + return False + + +def parse_spikegadgets_header(file: str | Path) -> str: + """ + Parse file (SpikeGadgets .rec format) into a string until "", + which is the last tag of the header, after which the binary data begins. + """ + header_size = None + with open(file, mode="rb") as f: + while True: + line = f.readline() + if b"" in line: + header_size = f.tell() + break + + if header_size is None: + ValueError("SpikeGadgets: the xml header does not contain ''") + + f.seek(0) + return f.read(header_size).decode("utf8") diff --git a/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec b/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec new file mode 100644 index 00000000..07ebe2e3 --- /dev/null +++ b/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec @@ -0,0 +1,1246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Ua'W+ `@p PPpP@ P p@`P  Pp@` `0` 0P0` + `P00`PP`p0`@0P``p00p0P 0@ 00P pPpP0p@p00pp@P0 ``00  @`0 PP@@Ppp 0`p@@@P0p@@@@P pP0PP@p0@ 0pP   @p@pp@ 0@0  @0`00`P`p0P ppP@` P p0` P@ Pp @PP@P0`0 ``U1W+ 0p0@0@ ` +0PP @@ P@@P0  0p p0P`0P @@`p `P p0 @jPp0@``pPPp0`@ pp@P` ` P p@0@@p`@pPP` p@p p@0P0pP@@   `  ``@`00 0 0P`P`P0@`@P@p`P00 `pPP @ PP@`P 0Pp0U