From 224fdd8b009f3854162d384ce9fa3b0bbedddc6e Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 05:28:44 -0500 Subject: [PATCH 01/11] Refine docstrings and comments for LocalAncestryObject and GlobalAncestryObject --- snputils/ancestry/genobj/local.py | 124 +++++++++++++++++------------- snputils/ancestry/genobj/wide.py | 86 +++++++++++---------- 2 files changed, 114 insertions(+), 96 deletions(-) diff --git a/snputils/ancestry/genobj/local.py b/snputils/ancestry/genobj/local.py index ca1a80a..262b2ec 100644 --- a/snputils/ancestry/genobj/local.py +++ b/snputils/ancestry/genobj/local.py @@ -13,10 +13,10 @@ class LocalAncestryObject(AncestryObject): """ def __init__( self, - haplotypes: List, + haplotypes: List[str], lai: np.ndarray, - samples: Optional[List] = None, - ancestry_map: Optional[Dict] = None, + samples: Optional[List[str]] = None, + ancestry_map: Optional[Dict[str, str]] = None, window_sizes: Optional[np.ndarray] = None, centimorgan_pos: Optional[np.ndarray] = None, chromosomes: Optional[np.ndarray] = None, @@ -24,14 +24,14 @@ def __init__( ) -> None: """ Args: - haplotypes (list): - A list of unique haplotype identifiers with a length of `n_haplotypes`. + haplotypes (list of str of length n_haplotypes): + A list of unique haplotype identifiers. lai (array of shape (n_windows, n_haplotypes)): A 2D array containing local ancestry inference values, where each row represents a genomic window, and each column corresponds to a haplotype phase for each sample. - samples (list, optional): - A list of unique sample identifiers with a length of `n_samples`. - ancestry_map (dict, optional): + samples (list of str of length n_samples, optional): + A list of unique sample identifiers. + ancestry_map (dict of str to str, optional): A dictionary mapping ancestry codes to region names. window_sizes (array of shape (n_windows,), optional): An array specifying the number of SNPs in each genomic window. @@ -83,32 +83,33 @@ def __setitem__(self, key, value): setattr(self, key, value) except AttributeError: raise KeyError(f'Invalid key: {key}') - + @property - def haplotypes(self) -> List: + def haplotypes(self) -> List[str]: """ Retrieve `haplotypes`. Returns: - List: A list of unique haplotype identifiers with a length of `n_samples*2`. + **list of length n_haplotypes:** A list of unique haplotype identifiers. """ return self.__haplotypes - + @haplotypes.setter def haplotypes(self, x): """ Update `haplotypes`. """ self.__haplotypes = x - + @property def lai(self) -> np.ndarray: """ Retrieve `lai`. Returns: - numpy.ndarray: A 2D array containing local ancestry inference values, where each row represents a - genomic window, and each column corresponds to a haplotype phase for each sample. + **array of shape (n_windows, n_haplotypes):** + A 2D array containing local ancestry inference values, where each row represents a + genomic window, and each column corresponds to a haplotype phase for each sample. """ return self.__lai @@ -120,12 +121,12 @@ def lai(self, x): self.__lai = x @property - def samples(self) -> Optional[List]: + def samples(self) -> Optional[List[str]]: """ Retrieve `samples`. Returns: - List: A list of unique sample identifiers with a length of `n_samples`. + **list of str:** A list of unique sample identifiers. """ return self.__samples @@ -137,12 +138,12 @@ def samples(self, x): self.__samples = x @property - def ancestry_map(self) -> Optional[Dict]: + def ancestry_map(self) -> Optional[Dict[str, str]]: """ Retrieve `ancestry_map`. Returns: - Dict: A dictionary mapping ancestry codes to region names. + **dict of str to str:** A dictionary mapping ancestry codes to region names. """ return self.__ancestry_map @@ -159,7 +160,8 @@ def window_sizes(self) -> Optional[np.ndarray]: Retrieve `window_sizes`. Returns: - numpy.ndarray: An array specifying the number of SNPs in each genomic window. + **array of shape (n_windows,):** + An array specifying the number of SNPs in each genomic window. """ return self.__window_sizes @@ -176,7 +178,8 @@ def centimorgan_pos(self) -> Optional[np.ndarray]: Retrieve `centimorgan_pos`. Returns: - numpy.ndarray: A 2D array containing the start and end centimorgan positions for each window. + **array of shape (n_windows, 2):** + A 2D array containing the start and end centimorgan positions for each window. """ return self.__centimorgan_pos @@ -193,7 +196,8 @@ def chromosomes(self) -> Optional[np.ndarray]: Retrieve `chromosomes`. Returns: - numpy.ndarray: An array with chromosome numbers corresponding to each genomic window. + **array of shape (n_windows,):** + An array with chromosome numbers corresponding to each genomic window. """ return self.__chromosomes @@ -210,7 +214,8 @@ def physical_pos(self) -> Optional[np.ndarray]: Retrieve `physical_pos`. Returns: - numpy.ndarray: A 2D array containing the start and end physical positions for each window. + **array of shape (n_windows, 2):** + A 2D array containing the start and end physical positions for each window. """ return self.__physical_pos @@ -227,8 +232,8 @@ def n_samples(self) -> int: Retrieve `n_samples`. Returns: - int: The total number of samples. If `samples` is available, returns its length; - otherwise, calculates based on the number of `haplotypes` or `lai` array dimensions. + **int:** + The total number of samples. """ if self.__samples is not None: return len(self.__samples) @@ -245,7 +250,7 @@ def n_ancestries(self) -> int: Retrieve `n_ancestries`. Returns: - int: The total number of unique ancestries. + **int:** The total number of unique ancestries. """ return len(np.unique(self.__lai)) @@ -255,7 +260,7 @@ def n_haplotypes(self) -> int: Retrieve `n_haplotypes`. Returns: - int: The total number of haplotypes. + **int:** The total number of haplotypes. """ if self.__haplotypes is not None: return len(self.__haplotypes) @@ -268,27 +273,28 @@ def n_windows(self) -> int: Retrieve `n_windows`. Returns: - int: The total number of genomic windows. + **int:** The total number of genomic windows. """ return self.__lai.shape[0] def copy(self) -> 'LocalAncestryObject': """ - Create and return a copy of the current `LocalAncestryObject` instance. + Create and return a copy of `self`. Returns: - LocalAncestryObject: + **LocalAncestryObject:** A new instance of the current object. """ return copy.copy(self) - def keys(self) -> List: + def keys(self) -> List[str]: """ - Retrieve a list of public attribute names for this `LocalAncestryObject` instance. + Retrieve a list of public attribute names for `self`. Returns: - List: A list of attribute names, with internal name-mangling removed, - for easier reference to public attributes in the instance. + **list of str:** + A list of attribute names, with internal name-mangling removed, + for easier reference to public attributes in the instance. """ return [attr.replace('_LocalAncestryObject__', '').replace('_AncestryObject__', '') for attr in vars(self)] @@ -299,16 +305,18 @@ def filter_windows( inplace: bool = False ) -> Optional['LocalAncestryObject']: """ - Filter genomic windows in the `LocalAncestryObject` based on their indexes. + Filter genomic windows based on specified indexes. - This method allows inclusion or exclusion of specific genomic windows from the - `LocalAncestryObject` by specifying their indexes. Negative indexes are supported - and follow NumPy's indexing conventions. It updates the `lai`, `chromosomes`, - `centimorgan_pos`, and `physical_pos` attributes accordingly. + This method updates the `lai` attribute to include or exclude the specified genomic windows. + Attributes such as `chromosomes`, `centimorgan_pos` and `physical_pos` will also be updated + accordingly if they are not None. The order of genomic windows is preserved. + + Negative indexes are supported and follow + [NumPy's indexing conventions](https://numpy.org/doc/stable/user/basics.indexing.html). Args: indexes (int or array-like of int): - Indexes of the windows to include or exclude. Can be a single integer or a + Index(es) of the windows to include or exclude. Can be a single integer or a sequence of integers. Negative indexes are supported. include (bool, default=True): If True, includes only the specified windows. If False, excludes the specified @@ -318,9 +326,9 @@ def filter_windows( the windows filtered. Default is False. Returns: - Optional[LocalAncestryObject]: Returns a new `LocalAncestryObject` with the specified - windows filtered if `inplace=False`. If `inplace=True`, modifies the object in place and - returns None. + **Optional[LocalAncestryObject]:** + A new `LocalAncestryObject` with the specified windows filtered if `inplace=False`. + If `inplace=True`, modifies `self` in place and returns None. """ # Convert indexes to a NumPy array indexes = np.atleast_1d(indexes) @@ -371,25 +379,30 @@ def filter_windows( def filter_samples( self, - samples: Union[str, Sequence[str], np.ndarray, None] = None, - indexes: Union[int, Sequence[int], np.ndarray, None] = None, + samples: Optional[Union[str, Sequence[str], np.ndarray, None]] = None, + indexes: Optional[Union[int, Sequence[int], np.ndarray, None]] = None, include: bool = True, inplace: bool = False ) -> Optional['LocalAncestryObject']: """ - Filter samples in the `LocalAncestryObject` based on sample names or indexes. + Filter samples based on specified names or indexes. + + This method updates the `lai`, `haplotypes`, and `samples` attributes to include or exclude the specified + samples. Each sample is associated with two haplotypes, which are included or excluded together. + The order of the samples is preserved. - This method allows inclusion or exclusion of specific samples by their names, - indexes, or both. When both samples and indexes are provided, the union of - the specified samples is used. Negative indexes are supported and follow NumPy's indexing - conventions. It updates the `lai`, `samples`, and `haplotypes` attributes accordingly. + If both samples and indexes are provided, any sample matching either a name in samples or an index in + indexes will be included or excluded. + + Negative indexes are supported and follow + [NumPy's indexing conventions](https://numpy.org/doc/stable/user/basics.indexing.html). Args: samples (str or array_like of str, optional): - Names of the samples to include or exclude. Can be a single sample name or a + Name(s) of the samples to include or exclude. Can be a single sample name or a sequence of sample names. Default is None. indexes (int or array_like of int, optional): - Indexes of the samples to include or exclude. Can be a single index or a sequence + Index(es) of the samples to include or exclude. Can be a single index or a sequence of indexes. Negative indexes are supported. Default is None. include (bool, default=True): If True, includes only the specified samples. If False, excludes the specified @@ -399,8 +412,9 @@ def filter_samples( samples filtered. Default is False. Returns: - Optional[LocalAncestryObject]: A new LocalAncestryObject with the specified samples - filtered if `inplace=False`. If inplace=True, modifies `self` in place and returns None. + **Optional[LocalAncestryObject]:** + A new `LocalAncestryObject` with the specified samples filtered if `inplace=False`. + If `inplace=True`, modifies `self` in place and returns None. """ if samples is None and indexes is None: raise UserWarning("At least one of 'samples' or 'indexes' must be provided.") @@ -491,7 +505,7 @@ def _sanity_check(self) -> None: def save(self, file: Union[str, pathlib.Path]) -> None: """ - Save the data stored in the `LocalAncestryObject` instance to a `.msp` file. + Save the data stored in `self` to a `.msp` file. Args: file (str or pathlib.Path): diff --git a/snputils/ancestry/genobj/wide.py b/snputils/ancestry/genobj/wide.py index 5ab5bee..3fa2470 100644 --- a/snputils/ancestry/genobj/wide.py +++ b/snputils/ancestry/genobj/wide.py @@ -26,14 +26,14 @@ def __init__( P (array of shape (n_snps, n_ancestries)): A 2D array containing per-ancestry SNP frequencies. Each row corresponds to a SNP, and each column corresponds to an ancestry. - samples (Sequence, optional): - A sequence containing unique identifiers for each sample. Length should be `n_samples`. - If None, sample identifiers are assigned as integers from `0` to `n_samples - 1`. - snps (Sequence, optional): - A sequence containing identifiers for each SNP. Length should be `n_snps`. - If None, SNPs are assigned as integers from `0` to `n_snps - 1`. - ancestries (Sequence, optional): - A sequence containing ancestry labels for each sample. Length should be `n_samples`. + samples (sequence of length n_samples, optional): + A sequence containing unique identifiers for each sample. If None, sample identifiers + are assigned as integers from `0` to `n_samples - 1`. + snps (sequence of length n_snps, optional): + A sequence containing identifiers for each SNP. If None, SNPs are assigned as integers + from `0` to `n_snps - 1`. + ancestries (sequence of length n_samples, optional): + A sequence containing ancestry labels for each sample. """ # Determine dimensions n_samples, n_ancestries_Q = Q.shape @@ -109,8 +109,9 @@ def Q(self) -> np.ndarray: Retrieve `Q`. Returns: - numpy.ndarray: A 2D array containing per-sample ancestry proportions. Each row corresponds - to a sample, and each column corresponds to an ancestry. + **array of shape (n_samples, n_ancestries):** + A 2D array containing per-sample ancestry proportions. Each row corresponds to a sample, + and each column corresponds to an ancestry. """ return self.__Q @@ -131,8 +132,9 @@ def P(self) -> np.ndarray: Retrieve `P`. Returns: - numpy.ndarray: A 2D array containing per-ancestry SNP frequencies. Each row corresponds to a SNP, - and each column corresponds to an ancestry. + **array of shape (n_snps, n_ancestries):** + A 2D array containing per-ancestry SNP frequencies. Each row corresponds to a SNP, + and each column corresponds to an ancestry. """ return self.__P @@ -154,8 +156,9 @@ def F(self) -> np.ndarray: Alias for `P`. Returns: - numpy.ndarray: A 2D array containing per-ancestry SNP frequencies. Each row corresponds to a SNP, - and each column corresponds to an ancestry. + **array of shape (n_snps, n_ancestries):** + A 2D array containing per-ancestry SNP frequencies. Each row corresponds to a SNP, + and each column corresponds to an ancestry. """ return self.P @@ -176,8 +179,9 @@ def samples(self) -> Optional[np.ndarray]: Retrieve `samples`. Returns: - numpy.ndarray: An array containing unique identifiers for each sample. If None, sample - identifiers are assigned as integers from `0` to `n_samples - 1`. + **array of shape (n_samples,):** + An array containing unique identifiers for each sample. If None, sample + identifiers are assigned as integers from `0` to `n_samples - 1`. """ return self.__samples @@ -199,8 +203,9 @@ def snps(self) -> Optional[np.ndarray]: Retrieve `snps`. Returns: - numpy.ndarray: An array containing identifiers for each SNP. Length should be `n_snps`. - If None, SNPs are assigned as integers from `0` to `n_snps - 1`. + **array of shape (n_snps,):** + An array containing identifiers for each SNP. If None, SNPs are assigned as integers + from `0` to `n_snps - 1`. """ return self.__snps @@ -216,14 +221,14 @@ def snps(self, x: Sequence): ) self.__snps = np.asarray(x) - @property def ancestries(self) -> Optional[np.ndarray]: """ Retrieve `ancestries`. Returns: - numpy.ndarray: An array containing ancestry labels for each sample. Length should be `n_samples`. + **array of shape (n_samples,):** + An array containing ancestry labels for each sample. """ return self.__ancestries @@ -252,7 +257,7 @@ def n_samples(self) -> int: Retrieve `n_samples`. Returns: - int: The total number of samples. + **int:** The total number of samples. """ return self.__Q.shape[0] @@ -262,7 +267,7 @@ def n_snps(self) -> int: Retrieve `n_snps`. Returns: - int: The total number of SNPs. + **int:** The total number of SNPs. """ return self.__P.shape[0] @@ -272,26 +277,27 @@ def n_ancestries(self) -> int: Retrieve `n_ancestries`. Returns: - int: The total number of unique ancestries. + **int:** The total number of unique ancestries. """ return self.__Q.shape[1] def copy(self) -> 'GlobalAncestryObject': """ - Create and return a copy of the current `GlobalAncestryObject` instance. + Create and return a copy of `self`. Returns: - GlobalAncestryObject: A new instance of the current object. + **GlobalAncestryObject:** A new instance of the current object. """ return copy.copy(self) - def keys(self) -> List: + def keys(self) -> List[str]: """ - Retrieve a list of public attribute names for this `GlobalAncestryObject` instance. + Retrieve a list of public attribute names for `self`. Returns: - List: A list of attribute names, with internal name-mangling removed, - for easier reference to public attributes in the instance. + **list of str:** + A list of attribute names, with internal name-mangling removed, + for easier reference to public attributes in the instance. """ return [attr.replace('_GlobalAncestryObject__', '').replace('_AncestryObject__', '') for attr in vars(self)] @@ -300,7 +306,7 @@ def _sanity_check(self) -> None: Perform sanity checks to ensure that matrix dimensions are consistent with expected sizes. Raises: - ValueError: If any of the matrix dimensions do not match the expected sizes. + **ValueError:** If any of the matrix dimensions do not match the expected sizes. """ # Check that the Q matrix has the correct shape if self.__Q.shape != (self.n_samples, self.n_ancestries): @@ -344,21 +350,19 @@ def _sanity_check(self) -> None: def save(self, file_prefix: Union[str, Path]) -> None: """ - Save the data stored in the `GlobalAncestryObject` instance into multiple ADMIXTURE files: - - - Q matrix file: `.K.Q` - - P matrix file: `.K.P` - - Sample IDs file: `.sample_ids.txt` (if sample IDs are available) - - SNP IDs file: `.snp_ids.txt` (if SNP IDs are available) - - Ancestry file: `.map` (if ancestries information is available) + Save the data stored in `self` into multiple ADMIXTURE files: - where `K` is the total number of ancestries. + - Q matrix file: `..Q`. + - P matrix file: `..P`. + - Sample IDs file: `.sample_ids.txt` (if sample IDs are available). + - SNP IDs file: `.snp_ids.txt` (if SNP IDs are available). + - Ancestry file: `.map` (if ancestries information is available). Args: file_prefix (str or pathlib.Path): - The prefix for the output file names, including any parent directories. - This prefix is used to generate the output file names and should not include - the file extensions. + The base prefix for output file names, including directory path but excluding file extensions. + The prefix is used to generate specific file names for each output, with file-specific + suffixes appended as described above (e.g., `file_prefix.K.Q` for the Q matrix file). """ from snputils.ancestry.io.wide.write.admixture import AdmixtureWriter From 26ca908e34a5dfefd02ab66b0c5317cd65d25874 Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 05:34:10 -0500 Subject: [PATCH 02/11] Fix bug in LocalAncestryObject's filter_windows to ensure are handled correctly --- snputils/ancestry/genobj/local.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/snputils/ancestry/genobj/local.py b/snputils/ancestry/genobj/local.py index 262b2ec..f2987de 100644 --- a/snputils/ancestry/genobj/local.py +++ b/snputils/ancestry/genobj/local.py @@ -308,8 +308,8 @@ def filter_windows( Filter genomic windows based on specified indexes. This method updates the `lai` attribute to include or exclude the specified genomic windows. - Attributes such as `chromosomes`, `centimorgan_pos` and `physical_pos` will also be updated - accordingly if they are not None. The order of genomic windows is preserved. + Attributes such as `window_sizes`, `centimorgan_pos`, `chromosomes`, and `physical_pos` will also be + updated accordingly if they are not None. The order of genomic windows is preserved. Negative indexes are supported and follow [NumPy's indexing conventions](https://numpy.org/doc/stable/user/basics.indexing.html). @@ -351,7 +351,8 @@ def filter_windows( # Filter `lai` filtered_lai = self['lai'][mask, :] - # Filter `chromosomes`, `centimorgan_pos`, and `physical_pos`, checking if they are None before filtering + # Filter `window_sizes`, `chromosomes`, `centimorgan_pos`, and `physical_pos`, checking if they are None before filtering + filtered_window_sizes = self['window_sizes'][mask] if self['window_sizes'] is not None else None filtered_chromosomes = self['chromosomes'][mask] if self['chromosomes'] is not None else None filtered_centimorgan_pos = self['centimorgan_pos'][mask, :] if self['centimorgan_pos'] is not None else None filtered_physical_pos = self['physical_pos'][mask, :] if self['physical_pos'] is not None else None @@ -359,6 +360,8 @@ def filter_windows( # Modify the original object if `inplace=True`, otherwise create and return a copy if inplace: self['lai'] = filtered_lai + if filtered_window_sizes is not None: + self['window_sizes'] = filtered_window_sizes if filtered_chromosomes is not None: self['chromosomes'] = filtered_chromosomes if filtered_centimorgan_pos is not None: @@ -369,6 +372,8 @@ def filter_windows( else: laiobj = self.copy() laiobj['lai'] = filtered_lai + if filtered_window_sizes is not None: + laiobj['window_sizes'] = filtered_window_sizes if filtered_chromosomes is not None: laiobj['chromosomes'] = filtered_chromosomes if filtered_centimorgan_pos is not None: From bdd8758b877f7049e014b35a97159bbbab1b1c5a Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 06:45:14 -0500 Subject: [PATCH 03/11] Fix bug: 'VCFWriter' object has no attribute '_VCFWriter__filename' --- snputils/snp/io/write/pgen.py | 2 +- snputils/snp/io/write/vcf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/snputils/snp/io/write/pgen.py b/snputils/snp/io/write/pgen.py index 3289c26..089c83a 100644 --- a/snputils/snp/io/write/pgen.py +++ b/snputils/snp/io/write/pgen.py @@ -27,7 +27,7 @@ def __init__(self, snpobj: SNPObject, filename: str): TODO: add support for parallel writing by chromosome. """ self.__snpobj = snpobj - self.__filename = Path(self.__filename) + self.__filename = Path(filename) def write(self): """ diff --git a/snputils/snp/io/write/vcf.py b/snputils/snp/io/write/vcf.py index 89e55a8..ed5fadf 100644 --- a/snputils/snp/io/write/vcf.py +++ b/snputils/snp/io/write/vcf.py @@ -29,7 +29,7 @@ def __init__(self, snpobj: SNPObject, filename: str, n_jobs: int = -1, phased: b "maternal/paternal" format. """ self.__snpobj = snpobj - self.__filename = Path(self.__filename) + self.__filename = Path(filename) self.__n_jobs = n_jobs self.__phased = phased From 392fef9fa63f5ab7adda78a5530bb1fd666b5dfc Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 07:06:35 -0500 Subject: [PATCH 04/11] Fix Path handling issues in VCFWriter, BEDWriter and PGENWriter --- snputils/snp/io/write/bed.py | 8 ++++---- snputils/snp/io/write/pgen.py | 2 +- snputils/snp/io/write/vcf.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/snputils/snp/io/write/bed.py b/snputils/snp/io/write/bed.py index be81e17..97cf854 100644 --- a/snputils/snp/io/write/bed.py +++ b/snputils/snp/io/write/bed.py @@ -27,7 +27,7 @@ def write(self): # Save .bed file if self.__filename.suffix != '.bed': - self.__filename = self.__filename.with_name(self.__filename.name + '.bed') + self.__filename = self.__filename.with_name(self.__filename.with_suffix('.bed')) log.info(f"Writing .bed file: {self.__filename}") @@ -40,7 +40,7 @@ def write(self): samples, variants = self.__snpobj.calldata_gt.shape # Define the PgenWriter to save the data - data_save = pg.PgenWriter(filename=self.__filename.encode('utf-8'), + data_save = pg.PgenWriter(filename=str(self.__filename).encode('utf-8'), sample_ct=samples, variant_ct=variants, nonref_flags=True, @@ -70,7 +70,7 @@ def write(self): fam_file['fid'] = self.__snpobj.samples # Save .fam file - fam_file.to_csv(self.__filename + '.fam', sep='\t', index=False, header=False) + fam_file.to_csv(self.__filename.with_suffix('.fam'), sep='\t', index=False, header=False) log.info(f"Finished writing .fam file: {self.__filename}") # Save .bim file @@ -87,5 +87,5 @@ def write(self): bim_file['a1'] = self.__snpobj.variants_ref # Save .bim file - bim_file.to_csv(self.__filename + '.bim', sep='\t', index=False, header=False) + bim_file.to_csv(self.__filename.with_suffix('.bim'), sep='\t', index=False, header=False) log.info(f"Finished writing .bim file: {self.__filename}") diff --git a/snputils/snp/io/write/pgen.py b/snputils/snp/io/write/pgen.py index 089c83a..46a60de 100644 --- a/snputils/snp/io/write/pgen.py +++ b/snputils/snp/io/write/pgen.py @@ -92,7 +92,7 @@ def write_pgen(self): flat_genotypes = self.__snpobj.__calldata_gt with pg.PgenWriter( - f"{self.__filename}.pgen".encode("utf-8"), + filename=str(self.__filename).encode('utf-8'), sample_ct=num_samples, variant_ct=num_variants, hardcall_phase_present=phased, diff --git a/snputils/snp/io/write/vcf.py b/snputils/snp/io/write/vcf.py index ed5fadf..5b993a0 100644 --- a/snputils/snp/io/write/vcf.py +++ b/snputils/snp/io/write/vcf.py @@ -104,9 +104,9 @@ def write_chromosome_data(self, chrom, data_chrom): # Format output file if chrom == "All": - file = self.__filename + self.__file_extension + file = self.__filename.with_suffix(self.__file_extension) else: - file = f'{self.__filename}_{chrom}{self.__file_extension}' + file = self.__filename.parent / f"{self.__filename.stem}_{chrom}{self.__file_extension}" # Write header with open(file, "w") as f: From f648e4d7b354e70150899bac2327b5daffc3aa02 Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 07:08:12 -0500 Subject: [PATCH 05/11] Refactor SNPObject and improve docstrings - Rename average_strands to is_phased. - Add common_variants_intersection to subset_to_common_variants. - Add common_markers_intersection to subset_to_common_markers. - Rename filename to file. - Rename correct_snp_variants to correct_flipped_variants. - Refine docstrings for improved clarity and consistency. --- demos/SNPObj.ipynb | 34 +- snputils/snp/genobj/snpobj.py | 589 +++++++++++++++++----------------- 2 files changed, 315 insertions(+), 308 deletions(-) diff --git a/demos/SNPObj.ipynb b/demos/SNPObj.ipynb index 4714189..210995b 100644 --- a/demos/SNPObj.ipynb +++ b/demos/SNPObj.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 1, "id": "674d136a-1ded-4ce0-babc-87556d518b5f", "metadata": {}, "outputs": [], @@ -319,7 +319,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "Unique genotype values before renaming missings: [0 1]\n", + "Unique genotype values before renaming missings: [0 1]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "Unique genotype values after renaming missings: [0 1]\n" ] } @@ -424,13 +430,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Number of SNPs before filtering by indexes: 976599\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "Number of SNPs before filtering by indexes: 976599\n", "Number of SNPs after filtering by indexes: 3\n" ] } @@ -466,7 +466,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "Samples before filtering: ['sample_A', 'sample_B', 'sample_C', 'sample_D']\n", + "Samples before filtering: ['sample_A', 'sample_B', 'sample_C', 'sample_D']\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "Samples after filtering: ['sample_A', 'sample_B']\n" ] } @@ -657,7 +663,7 @@ } ], "source": [ - "snpobj_corrected = snpobj.correct_snp_variants(snpobj2, check_complement=True, index_by='pos', inplace=False)\n", + "snpobj_corrected = snpobj.correct_flipped_variants(snpobj2, check_complement=True, index_by='pos', inplace=False)\n", "\n", "print(\"SNP flips corrected.\")" ] @@ -728,7 +734,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "First 5 variant positions after shuffling: [14258595 13978490 23721180 30763191 42617482]\n" + "First 5 variant positions after shuffling: [45903479 41864913 32108927 26948879 19065364]\n" ] } ], @@ -834,7 +840,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 26, "id": "fbca1fd8", "metadata": {}, "outputs": [ @@ -873,7 +879,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 27, "id": "cea856ad", "metadata": {}, "outputs": [ diff --git a/snputils/snp/genobj/snpobj.py b/snputils/snp/genobj/snpobj.py index 1434964..186204b 100644 --- a/snputils/snp/genobj/snpobj.py +++ b/snputils/snp/genobj/snpobj.py @@ -27,25 +27,25 @@ def __init__( ) -> None: """ Args: - calldata_gt (np.ndarray, optional): + calldata_gt (array, optional): An array containing genotype data for each sample. This array can be either 2D with shape `(n_snps, n_samples)` if the paternal and maternal strands are averaged, or 3D with shape `(n_snps, n_samples, 2)` if the strands are kept separate. - samples (np.ndarray of shape (n_sampels,), optional): + samples (array of shape (n_sampels,), optional): An array containing unique sample identifiers. - variants_ref (np.ndarray of shape (n_snps,), optional): + variants_ref (array of shape (n_snps,), optional): An array containing the reference allele for each SNP. - variants_alt (np.ndarray of shape (n_snps, 3), optional): + variants_alt (array of shape (n_snps, 3), optional): An array containing the alternate alleles for each SNP. - variants_chrom (np.ndarray of shape (n_snps,), optional): + variants_chrom (array of shape (n_snps,), optional): An array containing the chromosome for each SNP. - variants_filter_pass (np.ndarray of shape (n_snps,), optional): + variants_filter_pass (array of shape (n_snps,), optional): An array indicating whether each SNP passed control checks. - variants_id (np.ndarray of shape (n_snps,), optional): + variants_id (array of shape (n_snps,), optional): An array containing unique identifiers (IDs) for each SNP. - variants_pos (np.ndarray of shape (n_snps,), optional): + variants_pos (array of shape (n_snps,), optional): An array containing the chromosomal positions for each SNP. - variants_qual (np.ndarray of shape (n_snps,), optional): + variants_qual (array of shape (n_snps,), optional): An array containing the Phred-scaled quality score for each SNP. """ self.__calldata_gt = calldata_gt @@ -60,16 +60,8 @@ def __init__( def __getitem__(self, key: str) -> Any: """ - Enables dictionary-like access to class attributes. - - Args: - key (str): The attribute key to access. - - Returns: - Any: The value associated with 'key'. - - Raises: - KeyError: If 'key' does not correspond to an attribute. + To access an attribute of the class using the square bracket notation, + similar to a dictionary. """ try: return getattr(self, key) @@ -78,14 +70,8 @@ def __getitem__(self, key: str) -> Any: def __setitem__(self, key: str, value: Any): """ - Enables setting class attributes using dictionary-like square bracket notation. - - Args: - key (str): The attribute key to set. - value (Any): The value to assign to the attribute. - - Raises: - KeyError: If 'key' does not correspond to an attribute. + To set an attribute of the class using the square bracket notation, + similar to a dictionary. """ try: setattr(self, key, value) @@ -98,7 +84,10 @@ def calldata_gt(self) -> np.ndarray: Retrieve `calldata_gt`. Returns: - numpy.ndarray: An array containing the genotypes. + **array:** + An array containing genotype data for each sample. This array can be either 2D with shape + `(n_snps, n_samples)` if the paternal and maternal strands are averaged, or 3D with shape + `(n_snps, n_samples, 2)` if the strands are kept separate. """ return self.__calldata_gt @@ -115,10 +104,8 @@ def samples(self) -> Optional[np.ndarray]: Retrieve `samples`. Returns: - numpy.ndarray: - An array containing genotype data for each sample. This array can be either 2D with shape - (`n_samples`, `n_snps`) if the paternal and maternal strands are averaged, or 3D with shape - (`n_samples`, `n_snps`, 2) if the strands are kept separate. + **array of shape (n_sampels,):** + An array containing unique sample identifiers. """ return self.__samples @@ -126,9 +113,6 @@ def samples(self) -> Optional[np.ndarray]: def samples(self, x: np.ndarray): """ Update `samples`. - - Args: - x: An array containing unique sample identifiers. """ self.__samples = x @@ -138,7 +122,7 @@ def variants_ref(self) -> Optional[np.ndarray]: Retrieve `variants_ref`. Returns: - numpy.ndarray: An array containing the reference allele for each SNP. + **array of shape (n_snps,):** An array containing the reference allele for each SNP. """ return self.__variants_ref @@ -146,9 +130,6 @@ def variants_ref(self) -> Optional[np.ndarray]: def variants_ref(self, x: np.ndarray): """ Update `variants_ref`. - - Args: - x: The new value for `variants_ref`. """ self.__variants_ref = x @@ -158,7 +139,7 @@ def variants_alt(self) -> Optional[np.ndarray]: Retrieve `variants_alt`. Returns: - numpy.ndarray: An array containing the alternate alleles for each SNP. + **array of shape (n_snps, 3):** An array containing the alternate alleles for each SNP. """ return self.__variants_alt @@ -166,9 +147,6 @@ def variants_alt(self) -> Optional[np.ndarray]: def variants_alt(self, x: np.ndarray): """ Update `variants_alt`. - - Args: - x: The new value for `variants_alt`. """ self.__variants_alt = x @@ -178,7 +156,7 @@ def variants_chrom(self) -> Optional[np.ndarray]: Retrieve `variants_chrom`. Returns: - numpy.ndarray: An array containing the chromosome of each SNP. + **array of shape (n_snps,):** An array containing the chromosome for each SNP. """ return self.__variants_chrom @@ -186,9 +164,6 @@ def variants_chrom(self) -> Optional[np.ndarray]: def variants_chrom(self, x: np.ndarray): """ Update `variants_chrom`. - - Args: - x: An array containing the chromosome for each SNP. """ self.__variants_chrom = x @@ -198,7 +173,7 @@ def variants_filter_pass(self) -> Optional[np.ndarray]: Retrieve `variants_filter_pass`. Returns: - numpy.ndarray: An array indicating whether each SNP passed control checks. + **array of shape (n_snps,):** An array indicating whether each SNP passed control checks. """ return self.__variants_filter_pass @@ -206,9 +181,6 @@ def variants_filter_pass(self) -> Optional[np.ndarray]: def variants_filter_pass(self, x: np.ndarray): """ Update `variants_filter_pass`. - - Args: - x: The new value for `variants_filter_pass`. """ self.__variants_filter_pass = x @@ -218,7 +190,7 @@ def variants_id(self) -> Optional[np.ndarray]: Retrieve `variants_id`. Returns: - numpy.ndarray: An array containing unique identifiers (IDs) for each SNP. + **array of shape (n_snps,):** An array containing unique identifiers (IDs) for each SNP. """ return self.__variants_id @@ -226,9 +198,6 @@ def variants_id(self) -> Optional[np.ndarray]: def variants_id(self, x: np.ndarray): """ Update `variants_id`. - - Args: - x: The new value for `variants_id`. """ self.__variants_id = x @@ -238,7 +207,7 @@ def variants_pos(self) -> Optional[np.ndarray]: Retrieve `variants_pos`. Returns: - numpy.ndarray: Array containing the position of each SNP in the chromosome. + **array of shape (n_snps,):** An array containing the chromosomal positions for each SNP. """ return self.__variants_pos @@ -246,9 +215,6 @@ def variants_pos(self) -> Optional[np.ndarray]: def variants_pos(self, x: np.ndarray): """ Update `variants_pos`. - - Args: - x: An array containing the chromosomal positions for each SNP. """ self.__variants_pos = x @@ -258,7 +224,7 @@ def variants_qual(self) -> Optional[np.ndarray]: Retrieve `variants_qual`. Returns: - numpy.ndarray: Array containing the Phred-scaled quality score for each SNP. + **array of shape (n_snps,):** An array containing the Phred-scaled quality score for each SNP. """ return self.__variants_qual @@ -266,9 +232,6 @@ def variants_qual(self) -> Optional[np.ndarray]: def variants_qual(self, x: np.ndarray): """ Update `variants_qual`. - - Args: - x: An array containing the Phred-scaled quality score for each SNP. """ self.__variants_qual = x @@ -278,7 +241,7 @@ def n_samples(self) -> int: Retrieve `n_samples`. Returns: - int: The total number of samples. + **int:** The total number of samples. """ return self.__calldata_gt.shape[1] @@ -288,7 +251,7 @@ def n_snps(self) -> int: Retrieve `n_snps`. Returns: - int: The total number of SNPs. + **int:** The total number of SNPs. """ return self.__calldata_gt.shape[0] @@ -298,7 +261,7 @@ def n_chrom(self) -> Optional[int]: Retrieve `n_chrom`. Returns: - int: The total number of unique chromosomes in `variants_chrom`. + **int:** The total number of unique chromosomes in `variants_chrom`. """ if self.variants_chrom is None: warnings.warn("Chromosome data `variants_chrom` is None.") @@ -312,7 +275,7 @@ def unique_chrom(self) -> Optional[np.ndarray]: Retrieve `unique_chrom`. Returns: - numpy.ndarray: The unique chromosome names in `variants_chrom`, preserving their order of appearance. + **array:** The unique chromosome names in `variants_chrom`, preserving their order of appearance. """ if self.variants_chrom is None: warnings.warn("Chromosome data `variants_chrom` is None.") @@ -324,74 +287,73 @@ def unique_chrom(self) -> Optional[np.ndarray]: return self.variants_chrom[np.sort(idx)] @property - def average_strands(self) -> bool: + def is_phased(self) -> bool: """ - Retrieve `average_strands`. + Retrieve `is_phased`. Returns: - bool: True if the genotype data in `calldata_gt` represents averaged strands - (indicated by a 2D shape `(n_samples, n_snps)`), or False if the strands - are separated (indicated by a 3D shape `(n_samples, n_snps, 2)`). + **bool:** True if the genotype data in `calldata_gt` has shape + `(n_samples, n_snps, 2)`, False if it has shape `(n_samples, n_snps)`. """ if self.calldata_gt is None: warnings.warn("Genotype data `calldata_gt` is None.") return None - return self.calldata_gt.ndim == 2 + return self.calldata_gt.ndim == 3 def copy(self) -> 'SNPObject': """ - Create and return a copy of the current `SNPObject` instance. + Create and return a copy of `self`. Returns: - SNPObject: + **SNPObject:** A new instance of the current object. """ return copy.deepcopy(self) def keys(self) -> List[str]: """ - Retrieve a list of public attribute names for this `SNPObject` instance. + Retrieve a list of public attribute names for `self`. Returns: - List: A list of attribute names, with internal name-mangling removed, - for easier reference to public attributes in the instance. + **list of str:** + A list of attribute names, with internal name-mangling removed, + for easier reference to public attributes in the instance. """ return [attr.replace('_SNPObject__', '') for attr in vars(self)] def filter_variants( self, - chrom: Union[str, Sequence[str], np.ndarray, None] = None, - pos: Union[int, Sequence[int], np.ndarray, None] = None, - indexes: Union[int, Sequence[int], np.ndarray, None] = None, + chrom: Optional[Union[str, Sequence[str], np.ndarray, None]] = None, + pos: Optional[Union[int, Sequence[int], np.ndarray, None]] = None, + indexes: Optional[Union[int, Sequence[int], np.ndarray, None]] = None, include: bool = True, inplace: bool = False - ) -> 'SNPObject': + ) -> Optional['SNPObject']: """ - Filter variants in the `SNPObject` based on specified chromosome names, variant positions, - or variant indexes. + Filter variants based on specified chromosome names, variant positions, or variant indexes. + + This method updates the `calldata_gt`, `variants_ref`, `variants_alt`, + `variants_chrom`, `variants_filter_pass`, `variants_id`, `variants_pos`, and + `variants_qual` attributes to include or exclude the specified variants. The filtering + criteria can be based on chromosome names, variant positions, or indexes. If multiple + criteria are provided, their union is used for filtering. The order of the variants is preserved. - This method allows inclusion or exclusion of variants that match one or more of these criteria, - offering flexibility in filtering by chromosome, position, or indexes. When multiple criteria - are provided, the union of these criteria is used for filtering. Negative indexes are supported - and follow NumPy's indexing conventions. It updates the `calldata_gt`, `variants_ref`, `variants_alt`, - `variants_chrom`, `variants_filter_pass`, `variants_id`, `variants_pos`, ad `variants_qual` - attributes accordingly. + Negative indexes are supported and follow + [NumPy's indexing conventions](https://numpy.org/doc/stable/user/basics.indexing.html). Args: chrom (str or array_like of str, optional): - Chromosome(s) to filter variants by. Can be a single chromosome as a string (e.g., '21') - or a sequence of chromosomes (e.g., ['1', '1', '21']). If both `chrom` and `pos` - are provided, they must either have the same length (matching each chromosome to a - specific position) or `chrom` can be a single value, in which case all `pos` values - apply to that chromosome. Default is None. + Chromosome(s) to filter variants by. Can be a single chromosome as a string or a sequence + of chromosomes. If both `chrom` and `pos` are provided, they must either have matching lengths + (pairing each chromosome with a position) or `chrom` should be a single value that applies to + all positions in `pos`. Default is None. pos (int or array_like of int, optional): - Position(s) to filter variants by. Can be a single position as an integer (e.g., 123456) - or a sequence of positions (e.g., [100000, 200000, 300000]). If `chrom` is also - provided, each position in `pos` should correspond to a chromosome in `chrom`, or - `chrom` should be a single value. Default is None. + Position(s) to filter variants by. Can be a single position as an integer or a sequence of positions. + If `chrom` is also provided, `pos` should either match `chrom` in length or `chrom` should be a + single value. Default is None. indexes (int or array_like of int, optional): - Indexes of the variants to include or exclude. Can be a single index or a sequence + Index(es) of the variants to include or exclude. Can be a single index or a sequence of indexes. Negative indexes are supported. Default is None. include (bool, default=True): If True, includes only the specified variants. If False, excludes the specified @@ -401,8 +363,9 @@ def filter_variants( filtered. Default is False. Returns: - SNPObject or None: If inplace=False, return the modified object. Otherwise, the - operation is done in-place and None is returned. + **Optional[SNPObject]:** + A new `SNPObject` with the specified variants filtered if `inplace=False`. + If `inplace=True`, modifies `self` in place and returns None. """ if chrom is None and pos is None and indexes is None: raise ValueError("At least one of 'chrom', 'pos', or 'indexes' must be provided.") @@ -417,7 +380,10 @@ def filter_variants( # Validate chrom and pos lengths if both are provided if chrom is not None and pos is not None: if len(chrom) != len(pos) and len(chrom) > 1: - raise ValueError("When both 'chrom' and 'pos' are provided, they must either be of the same length or 'chrom' must be a single value.") + raise ValueError( + "When both 'chrom' and 'pos' are provided, they must either be of the same length " + "or 'chrom' must be a single value." + ) # Create a mask for chromosome and position filtering mask_combined = np.zeros(n_snps, dtype=bool) @@ -427,8 +393,20 @@ def filter_variants( mask_combined = (self['variants_chrom'] == chrom[0]) & np.isin(self['variants_pos'], pos) else: # Vectorized pair matching for chrom and pos - query_pairs = np.array(list(zip(chrom, pos)), dtype=[('chrom', self['variants_chrom'].dtype), ('pos', self['variants_pos'].dtype)]) - data_pairs = np.array(list(zip(self['variants_chrom'], self['variants_pos'])), dtype=[('chrom', self['variants_chrom'].dtype), ('pos', self['variants_pos'].dtype)]) + query_pairs = np.array( + list(zip(chrom, pos)), + dtype=[ + ('chrom', self['variants_chrom'].dtype), + ('pos', self['variants_pos'].dtype) + ] + ) + data_pairs = np.array( + list(zip(self['variants_chrom'], self['variants_pos'])), + dtype=[ + ('chrom', self['variants_chrom'].dtype), + ('pos', self['variants_pos'].dtype) + ] + ) mask_combined = np.isin(data_pairs, query_pairs) elif chrom is not None: @@ -470,49 +448,46 @@ def filter_variants( for key in keys: if self[key] is not None: self[key] = np.asarray(self[key])[mask_combined] + self['calldata_gt'] = np.asarray(self['calldata_gt'])[mask_combined, ...] - # Handle `calldata_gt` based on `average_strands` property - if self.average_strands: # 2D case - self['calldata_gt'] = np.asarray(self['calldata_gt'])[mask_combined, :] - else: # 3D case - self['calldata_gt'] = np.asarray(self['calldata_gt'])[mask_combined, :, :] return None else: - # Create a new SNPObject with filtered data + # Create A new `SNPObject` with filtered data snpobj = self.copy() for key in keys: if snpobj[key] is not None: snpobj[key] = np.asarray(snpobj[key])[mask_combined] - - # Filter `calldata_gt` based on shape - if self.average_strands: # 2D case - snpobj['calldata_gt'] = np.asarray(self['calldata_gt'])[mask_combined, :] - else: # 3D case - snpobj['calldata_gt'] = np.asarray(self['calldata_gt'])[mask_combined, :, :] + snpobj['calldata_gt'] = np.asarray(self['calldata_gt'])[mask_combined, ...] return snpobj def filter_samples( self, - samples: Union[str, Sequence[str], np.ndarray, None] = None, - indexes: Union[int, Sequence[int], np.ndarray, None] = None, + samples: Optional[Union[str, Sequence[str], np.ndarray, None]] = None, + indexes: Optional[Union[int, Sequence[int], np.ndarray, None]] = None, include: bool = True, inplace: bool = False ) -> Optional['SNPObject']: """ - Filter samples in the `SNPObject` based on sample names or indexes. + Filter samples based on specified names or indexes. + + This method updates the `samples` and `calldata_gt` attributes to include or exclude the specified + samples. The order of the samples is preserved. + + If both samples and indexes are provided, any sample matching either a name in samples or an index in + indexes will be included or excluded. - This method allows inclusion or exclusion of specific samples by their names, - indexes, or both. When both samples and indexes are provided, the union of - the specified samples is used. Negative indexes are supported and follow NumPy's indexing - conventions. It updates the `samples` and `calldata_gt` attributes accordingly. + This method allows inclusion or exclusion of specific samples by their names or + indexes. When both sample names and indexes are provided, the union of the specified samples + is used. Negative indexes are supported and follow + [NumPy's indexing conventions](https://numpy.org/doc/stable/user/basics.indexing.html). Args: samples (str or array_like of str, optional): - Names of the samples to include or exclude. Can be a single sample name or a + Name(s) of the samples to include or exclude. Can be a single sample name or a sequence of sample names. Default is None. indexes (int or array_like of int, optional): - Indexes of the samples to include or exclude. Can be a single index or a sequence + Index(es) of the samples to include or exclude. Can be a single index or a sequence of indexes. Negative indexes are supported. Default is None. include (bool, default=True): If True, includes only the specified samples. If False, excludes the specified @@ -522,8 +497,9 @@ def filter_samples( filtered. Default is False. Returns: - Optional[SNPObject]: A new SNPObject with the specified samples - filtered if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. + **Optional[SNPObject]:** + A new `SNPObject` with the specified samples filtered if `inplace=False`. + If `inplace=True`, modifies `self` in place and returns None. """ if samples is None and indexes is None: raise ValueError("At least one of 'samples' or 'indexes' must be provided.") @@ -567,11 +543,8 @@ def filter_samples( # Filter `samples` filtered_samples = sample_names[mask_combined].tolist() - # Filter `calldata_gt` based on shape - if self.average_strands: # 2D case - filtered_calldata_gt = np.array(self['calldata_gt'])[:, mask_combined] - else: # 3D case - filtered_calldata_gt = np.array(self['calldata_gt'])[:, mask_combined, :] + # Filter `calldata_gt` + filtered_calldata_gt = np.array(self['calldata_gt'])[:, mask_combined, ...] if inplace: self['samples'] = filtered_samples @@ -585,23 +558,22 @@ def filter_samples( def detect_chromosome_format(self) -> str: """ - Detect the chromosome naming convention used in the SNPObject, based on the prefix - or format of the first chromosome identifier in `self.unique_chrom` to determine which - format is in use. + Detect the chromosome naming convention in `variants_chrom` based on the prefix + of the first chromosome identifier in `unique_chrom`. - Recognized formats are as follows: + **Recognized formats:** - - 'chr': Format with 'chr' prefix, e.g., 'chr1', 'chr2', ..., 'chrX', 'chrY', 'chrM'. - - 'chm': Format with 'chm' prefix, e.g., 'chm1', 'chm2', ..., 'chmX', 'chmY', 'chmM'. - - 'chrom': Format with 'chrom' prefix, e.g., 'chrom1', 'chrom2', ..., 'chromX', 'chromY', 'chromM'. - - 'plain': Plain format without a prefix, e.g., '1', '2', ..., 'X', 'Y', 'M'. + - `'chr'`: Format with 'chr' prefix, e.g., 'chr1', 'chr2', ..., 'chrX', 'chrY', 'chrM'. + - `'chm'`: Format with 'chm' prefix, e.g., 'chm1', 'chm2', ..., 'chmX', 'chmY', 'chmM'. + - `'chrom'`: Format with 'chrom' prefix, e.g., 'chrom1', 'chrom2', ..., 'chromX', 'chromY', 'chromM'. + - `'plain'`: Plain format without a prefix, e.g., '1', '2', ..., 'X', 'Y', 'M'. - If the format does not match any recognized patterns, 'Unknown format' is returned. + If the format does not match any recognized pattern, `'Unknown format'` is returned. Returns: - str: - A string indicating the detected chromosome format ('chr', 'chm', 'chrom', or 'plain'). - If no recognized format is matched, returns 'Unknown format'. + **str:** + A string indicating the detected chromosome format (`'chr'`, `'chm'`, `'chrom'`, or `'plain'`). + If no recognized format is matched, returns `'Unknown format'`. """ # Select the first unique chromosome identifier for format detection chromosome_str = self.unique_chrom[0] @@ -629,25 +601,26 @@ def convert_chromosome_format( inplace: bool = False ) -> Optional['SNPObject']: """ - Convert the chromosome format from one standard naming convention to another. Supported formats - include variations with prefixes ('chr', 'chm', 'chrom') and a plain format without prefixes. + Convert the chromosome format from one naming convention to another in `variants_chrom`. + + **Supported formats:** + + - `'chr'`: Format with 'chr' prefix, e.g., 'chr1', 'chr2', ..., 'chrX', 'chrY', 'chrM'. + - `'chm'`: Format with 'chm' prefix, e.g., 'chm1', 'chm2', ..., 'chmX', 'chmY', 'chmM'. + - `'chrom'`: Format with 'chrom' prefix, e.g., 'chrom1', 'chrom2', ..., 'chromX', 'chromY', 'chromM'. + - `'plain'`: Plain format without a prefix, e.g., '1', '2', ..., 'X', 'Y', 'M'. Args: from_format (str): - The current format of the chromosome data. Acceptable values are: - - 'chr': Format with 'chr' prefix, e.g., 'chr1', 'chr2', ..., 'chrX', 'chrY', 'chrM'. - - 'chm': Format with 'chm' prefix, e.g., 'chm1', 'chm2', ..., 'chmX', 'chmY', 'chmM'. - - 'chrom': Format with 'chrom' prefix, e.g., 'chrom1', 'chrom2', ..., 'chromX', 'chromY', 'chromM'. - - 'plain': Plain format without a prefix, e.g., '1', '2', ..., 'X', 'Y', 'M'. + The current chromosome format. Acceptable values are `'chr'`, `'chm'`, `'chrom'`, or `'plain'`. to_format (str): - The target format to which chromosome data should be converted. Acceptable values - are the same as `from_format` options ('chr', 'chm', 'chrom', 'plain'). + The target format for chromosome data conversion. Acceptable values match `from_format` options. inplace (bool, default=False): - If True, modifies `self` in place. If False, returns a new `SNPObject` with the chromosomes - renamed. Default is False. + If True, modifies `self` in place. If False, returns a new `SNPObject` with the converted format. + Default is False. Returns: - Optional[SNPObject]: A new SNPObject with the converted chromosome format if `inplace=False`. + **Optional[SNPObject]:** A new `SNPObject` with the converted chromosome format if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Define the list of standard chromosome identifiers @@ -689,25 +662,25 @@ def convert_chromosome_format( def match_chromosome_format(self, snpobj: 'SNPObject', inplace: bool = False) -> Optional['SNPObject']: """ - Convert the chromosome format of `self` to match the chromosome format of a reference `snpobj`. + Convert the chromosome format in `variants_chrom` from `self` to match the format of a reference `snpobj`. - Recognized formats are as follows: + **Recognized formats:** - - 'chr': Format with 'chr' prefix, e.g., 'chr1', 'chr2', ..., 'chrX', 'chrY', 'chrM'. - - 'chm': Format with 'chm' prefix, e.g., 'chm1', 'chm2', ..., 'chmX', 'chmY', 'chmM'. - - 'chrom': Format with 'chrom' prefix, e.g., 'chrom1', 'chrom2', ..., 'chromX', 'chromY', 'chromM'. - - 'plain': Plain format without a prefix, e.g., '1', '2', ..., 'X', 'Y', 'M'. + - `'chr'`: Format with 'chr' prefix, e.g., 'chr1', 'chr2', ..., 'chrX', 'chrY', 'chrM'. + - `'chm'`: Format with 'chm' prefix, e.g., 'chm1', 'chm2', ..., 'chmX', 'chmY', 'chmM'. + - `'chrom'`: Format with 'chrom' prefix, e.g., 'chrom1', 'chrom2', ..., 'chromX', 'chromY', 'chromM'. + - `'plain'`: Plain format without a prefix, e.g., '1', '2', ..., 'X', 'Y', 'M'. Args: snpobj (SNPObject): - The reference SNPObject to compare against. + The reference SNPObject whose chromosome format will be matched. inplace (bool, default=False): If True, modifies `self` in place. If False, returns a new `SNPObject` with the chromosome format matching that of `snpobj`. Default is False. Returns: - Optional[SNPObject]: - A new SNPObject with matched chromosome format if `inplace=False`. + **Optional[SNPObject]:** + A new `SNPObject` with matched chromosome format if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Detect the chromosome naming format of the current SNPObject @@ -731,23 +704,22 @@ def rename_chrom( inplace: bool = False ) -> Optional['SNPObject']: """ - Replace the chromosome values in `self` based on flexible patterns or exact matches. + Replace chromosome values in `variants_chrom` using patterns or exact matches. - This method is an alternative to `convert_chromosome_format` and allows for broader customization - using regex patterns or exact string replacements, making it useful for non-standard chromosome - formats or custom transformations. For common naming conventions (e.g., converting 'chr1' to '1' - or vice-versa), consider using `convert_chromosome_format`. + This method allows flexible chromosome replacements, using regex or exact matches, useful + for non-standard chromosome formats. For standard conversions (e.g., 'chr1' to '1'), + consider `convert_chromosome_format`. Args: to_replace (dict, str, or list of str): - Defines the pattern(s) or exact values to be replaced in chromosome names. Default behavior - transforms `` to `chr` and vice versa. If a chromosome value does not - match these patterns, it remains unchanged. + Pattern(s) or exact value(s) to be replaced in chromosome names. Default behavior + transforms `` to `chr` or vice versa. Non-matching values + remain unchanged. - If str or list of str: Matches will be replaced with `value`. - If regex (bool), then any regex matches will be replaced with `value`. - - If dict: Keys are the values to replace, with corresponding replacements as values. + - If dict: Keys defines values to replace, with corresponding replacements as values. value (str or list of str, optional): - Replacement value(s) to use if `to_replace` is a list or string. Ignored if `to_replace` + Replacement value(s) if `to_replace` is a string or list. Ignored if `to_replace` is a dictionary. regex (bool, default=True): If True, interprets `to_replace` keys as regex patterns. @@ -756,7 +728,7 @@ def rename_chrom( renamed. Default is False. Returns: - Optional[SNPObject]: A new SNPObject with the renamed chromosome format if `inplace=False`. + **Optional[SNPObject]:** A new `SNPObject` with the renamed chromosome format if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Standardize input format: convert `to_replace` and `value` to a dictionary if needed @@ -788,27 +760,28 @@ def rename_chrom( def rename_missings( self, before: Union[int, float, str] = -1, - after: Union[int, float, str] = ".", + after: Union[int, float, str] = '.', inplace: bool = False ) -> Optional['SNPObject']: """ Replace missing values in the `calldata_gt` attribute. - This method identifies missing values in the 'calldata_gt' attribute and replaces them with a specified - value. By default, it replaces occurrences of -1 (often used to signify missing data) with ".". + This method identifies missing values in 'calldata_gt' and replaces them with a specified + value. By default, it replaces occurrences of `-1` (often used to signify missing data) with `'.'`. Args: before (int, float, or str, default=-1): - The current representation of missing values in `calldata_gt`. Common values might be -1 or NaN. - after (int, float, or str, default="."): - The value that will replace `before`. + The current representation of missing values in `calldata_gt`. Common values might be -1, '.', or NaN. + Default is -1. + after (int, float, or str, default='.'): + The value that will replace `before`. Default is '.'. inplace (bool, default=False): If True, modifies `self` in place. If False, returns a new `SNPObject` with the applied - replacements. + replacements. Default is False. Returns: - Optional[SNPObject]: - A new SNPObject with the renamed missing values if `inplace=False`. + **Optional[SNPObject]:** + A new `SNPObject` with the renamed missing values if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Rename missing values in the `calldata_gt` attribute based on inplace flag @@ -826,7 +799,8 @@ def get_common_variants_intersection( index_by: str = 'pos' ) -> Tuple[List[str], np.ndarray, np.ndarray]: """ - Identify common variants between two `SNPObject` instances based on specified criteria (e.g., position, ID, or both). + Identify common variants between `self` and the `snpobj` instance based on the specified `index_by` criterion, + which may match based on chromosome and position (`variants_chrom`, `variants_pos`), ID (`variants_id`), or both. This method returns the identifiers of common variants and their corresponding indices in both objects. @@ -834,19 +808,17 @@ def get_common_variants_intersection( snpobj (SNPObject): The reference SNPObject to compare against. index_by (str, default='pos'): - The criterion for matching variants. Options include: - - 'pos': Matches based on chromosome and position (e.g., 'chr1-12345'). - - 'id': Matches based on variant ID alone (e.g., 'rs123'). - - 'pos+id': Matches based on a combination of chromosome, position, and ID (e.g., 'chr1-12345-rs123'). + Criteria for matching variants. Options: + - `'pos'`: Matches by chromosome and position (`variants_chrom`, `variants_pos`), e.g., 'chr1-12345'. + - `'id'`: Matches by variant ID alone (`variants_id`), e.g., 'rs123'. + - `'pos+id'`: Matches by chromosome, position, and ID (`variants_chrom`, `variants_pos`, `variants_id`), e.g., 'chr1-12345-rs123'. Default is 'pos'. Returns: - List of str: - A list of common variant identifiers (as strings). - numpy.ndarray: - An array of indices in `self` where common variants are located. - numpy.ndarray: - An array of indices in `snpobj` where common variants are located. + Tuple containing: + - **list of str:** A list of common variant identifiers (as strings). + - **array:** An array of indices in `self` where common variants are located. + - **array:** An array of indices in `snpobj` where common variants are located. """ # Create unique identifiers for each variant in both SNPObjects based on the specified criterion if index_by == 'pos': @@ -879,22 +851,21 @@ def get_common_markers_intersection( snpobj: 'SNPObject' ) -> Tuple[List[str], np.ndarray, np.ndarray]: """ - Identify common markers between two `SNPObject` instances. - The criteria to identify common markers are avariants at the same chrom, pos, ref and alt. + Identify common markers between between `self` and the `snpobj` instance. Common markers are identified + based on matching chromosome (`variants_chrom`), position (`variants_pos`), reference (`variants_ref`), + and alternate (`variants_alt`) alleles. This method returns the identifiers of common markers and their corresponding indices in both objects. Args: snpobj (SNPObject): - Another SNPObject to compare against. + The reference SNPObject to compare against. Returns: - List of str: - A list of common marker identifiers (as strings). - numpy.ndarray: - An array of indices in `self` where common markers are located. - numpy.ndarray: - An array of indices in `snpobj` where common markers are located. + Tuple containing: + - **list of str:** A list of common variant identifiers (as strings). + - **array:** An array of indices in `self` where common variants are located. + - **array:** An array of indices in `snpobj` where common variants are located. """ # Generate unique identifiers based on chrom, pos, ref, and alt alleles query_identifiers = [ @@ -915,73 +886,96 @@ def get_common_markers_intersection( return list(common_ids), np.array(query_idx), np.array(reference_idx) - def subset_to_common_variants(self, snpobj: 'SNPObject', index_by: str = 'pos', inplace: bool = False) -> Optional['SNPObject']: + def subset_to_common_variants( + self, + snpobj: 'SNPObject', + index_by: str = 'pos', + common_variants_intersection: Optional[Tuple[np.ndarray, np.ndarray]] = None, + inplace: bool = False + ) -> Optional['SNPObject']: """ - Subset `self` to contain only the common variants with the provided SNPObject based on - specified criteria (e.g., position, ID, or both). - - This method reduces `self` to include only the variants that have matching identifiers in - `snpobj` according to the chosen `index_by` criterion. - + Subset `self` to include only the common variants with a reference `snpobj` based on + the specified `index_by` criterion, which may match based on chromosome and position + (`variants_chrom`, `variants_pos`), ID (`variants_id`), or both. + Args: snpobj (SNPObject): The reference SNPObject to compare against. index_by (str, default='pos'): - The criterion for matching variants. Options include: - - 'pos': Matches based on chromosome and position (e.g., 'chr1-12345'). - - 'id': Matches based on variant ID alone. - - 'pos+id': Matches based on a combination of chromosome, position, and ID (e.g., 'chr1-12345-rs123'). + Criteria for matching variants. Options: + - `'pos'`: Matches by chromosome and position (`variants_chrom`, `variants_pos`), e.g., 'chr1-12345'. + - `'id'`: Matches by variant ID alone (`variants_id`), e.g., 'rs123'. + - `'pos+id'`: Matches by chromosome, position, and ID (`variants_chrom`, `variants_pos`, `variants_id`), e.g., 'chr1-12345-rs123'. Default is 'pos'. + common_variants_intersection (Tuple[np.ndarray, np.ndarray], optional): + Precomputed indices of common variants between `self` and `snpobj`. If None, intersection is + computed within the function. inplace (bool, default=False): If True, modifies `self` in place. If False, returns a new `SNPObject` with the common variants subsetted. Default is False. Returns: - SNPObject: - Optional[SNPObject]: A new SNPObject with the common variants subsetted if `inplace=False`. + **Optional[SNPObject]:** + Optional[SNPObject]: A new `SNPObject` with the common variants subsetted if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ - # Use get_common_variants_intersection to get common variants and their indices - _, query_idx, _ = self.get_common_variants_intersection(snpobj, index_by=index_by) + # Get indices of common variants if not provided + if common_variants_intersection is None: + _, query_idx, _ = self.get_common_variants_intersection(snpobj, index_by=index_by) + else: + query_idx, _ = common_variants_intersection # Use filter_variants method with the identified indices, applying `inplace` as specified return self.filter_variants(indexes=query_idx, include=True, inplace=inplace) - def subset_to_common_markers(self, snpobj: 'SNPObject', inplace: bool = False) -> Optional['SNPObject']: + def subset_to_common_markers( + self, + snpobj: 'SNPObject', + common_markers_intersection: Optional[Tuple[np.ndarray, np.ndarray]] = None, + inplace: bool = False + ) -> Optional['SNPObject']: """ - Subset `self` to contain only the common markers with the provided SNPObject. - The criteria to identify common markers are avariants at the same `chrom`, `pos`, `ref` and `alt`. + Subset `self` to include only the common markers with a reference `snpobj`. Common markers are identified + based on matching chromosome (`variants_chrom`), position (`variants_pos`), reference (`variants_ref`), + and alternate (`variants_alt`) alleles. Args: snpobj (SNPObject): The reference SNPObject to compare against. + common_markers_intersection (tuple of arrays, optional): + Precomputed indices of common markers between `self` and `snpobj`. If None, intersection is + computed within the function. inplace (bool, default=False): If True, modifies `self` in place. If False, returns a new `SNPObject` with the common markers subsetted. Default is False. Returns: - SNPObject: - Optional[SNPObject]: A new SNPObject with the common markers subsetted if `inplace=False`. + **SNPObject:** + **Optional[SNPObject]:** A new `SNPObject` with the common markers subsetted if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ - # Use get_common_markers_intersection to get common markers and their indices - _, query_idx, _ = self.get_common_markers_intersection(snpobj) + # Get indices of common markers if not provided + if common_markers_intersection is None: + _, query_idx, _ = self.get_common_markers_intersection(snpobj) + else: + query_idx, _ = common_markers_intersection # Use filter_variants method with the identified indices, applying `inplace` as specified return self.filter_variants(indexes=query_idx, include=True, inplace=inplace) def remove_strand_ambiguous_variants(self, inplace: bool = False) -> Optional['SNPObject']: """ - A strand-ambiguous variant has `ref` and `alt` alleles in the pairs A/T, T/A, C/G, or G/C, where - both alleles are complementary and thus indistinguishable in terms of strand orientation. + A strand-ambiguous variant has reference (`variants_ref`) and alternate (`variants_alt`) alleles + in the pairs A/T, T/A, C/G, or G/C, where both alleles are complementary and thus indistinguishable + in terms of strand orientation. Args: inplace (bool, default=False): If True, modifies `self` in place. If False, returns a new `SNPObject` with the - ambiguous variants removed. Default is False. + strand-ambiguous variants removed. Default is False. Returns: - Optional[SNPObject]: A new SNPObject with non-ambiguous variants only if `inplace=False`. + **Optional[SNPObject]:** A new `SNPObject` with non-ambiguous variants only if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Identify strand-ambiguous SNPs using vectorized comparisons @@ -1011,7 +1005,7 @@ def remove_strand_ambiguous_variants(self, inplace: bool = False) -> Optional['S log.debug(f'Removing {total_ambiguous} strand-ambiguous variants...') return self.filter_variants(indexes=non_ambiguous_idx, include=True, inplace=inplace) - def correct_snp_variants( + def correct_flipped_variants( self, snpobj: 'SNPObject', check_complement: bool = True, @@ -1021,46 +1015,50 @@ def correct_snp_variants( inplace: bool = False ) -> Optional['SNPObject']: """ - Correct variant flips between `self` and a reference `snpobj`. A flipped variant has swapped - `ref` and `alt` alleles between `self` and the reference `snpobj`. + Correct flipped variants between between `self` and a reference `snpobj`, where reference (`variants_ref`) + and alternate (`variants_alt`) alleles are swapped. - **Behavior based on `check_complement`:** + **Flip Detection Based on `check_complement`:** - - If `check_complement=False`, only exact allele swaps are considered: - 1. **Direct Swap**: `self['ref'] == snpobj['alt']` and `self['alt'] == snpobj['ref']` + - If `check_complement=False`, only direct allele swaps are considered: + 1. **Direct Swap:** `self.variants_ref == snpobj.variants_alt` and `self.variants_alt == snpobj.variants_ref`. - - If `check_complement=True`, four cases are considered: - 1. **Direct Swap**: `self['ref'] == snpobj['alt']` and `self['alt'] == snpobj['ref']` - 2. **Complement Swap of `ref`**: `complement(self['ref']) == snpobj['alt']` and `self['alt'] == snpobj['ref']` - 3. **Complement Swap of `alt`**: `self['ref'] == snpobj['alt']` and `complement(self['alt']) == snpobj['ref']` - 4. **Complement Swap of both `ref` and `alt`**: `complement(self['ref']) == snpobj['alt']` and `complement(self['alt']) == snpobj['ref']` + - If `check_complement=True`, both direct and complementary swaps are considered, with four possible cases: + 1. **Direct Swap:** `self.variants_ref == snpobj.variants_alt` and `self.variants_alt == snpobj.variants_ref`. + 2. **Complement Swap of Ref:** `complement(self.variants_ref) == snpobj.variants_alt` and `self.variants_alt == snpobj.variants_ref`. + 3. **Complement Swap of Alt:** `self.variants_ref == snpobj.variants_alt` and `complement(self.variants_alt) == snpobj.variants_ref`. + 4. **Complement Swap of both Ref and Alt:** `complement(self.variants_ref) == snpobj.variants_alt` and `complement(self.variants_alt) == snpobj.variants_ref`. - **Note:** Variants where `ref == alt` in `self` are ignored as they are ambiguous. + **Note:** Variants where `self.variants_ref == self.variants_alt` are ignored as they are ambiguous. - Correction involves: - - Swapping `ref` and `alt` alleles. - - Flipping genotype calls (0 becomes 1, and 1 becomes 0) to match the updated alleles. + **Correction Process:** + - Swaps `variants_ref` and `variants_alt` alleles in `self` to align with `snpobj`. + - Flips `calldata_gt` values (0 becomes 1, and 1 becomes 0) to match the updated allele configuration. Args: snpobj (SNPObject): The reference SNPObject to compare against. check_complement (bool, default=True): - If True, also checks for complementary base pairs (e.g., A<->T and C<->G) when identifying swapped variants. + If True, also checks for complementary base pairs (A/T, T/A, C/G, and G/C) when identifying swapped variants. Default is True. index_by (str, default='pos'): - Determines whether to match variants by 'pos' (chromosome-position) or 'id'. Default is 'pos'. - common_variants_intersection (Tuple[numpy.ndarray, numpy.ndarray]], optional): - Precomputed indices of common variants between `self` and the reference `snpobj`. - If None, the intersection is computed within the function. + Criteria for matching variants. Options: + - `'pos'`: Matches by chromosome and position (`variants_chrom`, `variants_pos`), e.g., 'chr1-12345'. + - `'id'`: Matches by variant ID alone (`variants_id`), e.g., 'rs123'. + - `'pos+id'`: Matches by chromosome, position, and ID (`variants_chrom`, `variants_pos`, `variants_id`), e.g., 'chr1-12345-rs123'. + Default is 'pos'. + common_variants_intersection (tuple of arrays, optional): + Precomputed indices of common variants between `self` and `snpobj`. If None, intersection is + computed within the function. log_stats (bool, default=True): If True, logs statistical information about matching and ambiguous alleles. Default is True. inplace (bool, default=False): - If True, modifies `self` in place. If False, returns a new `SNPObject` with the - flipped variants corrected. Default is False. + If True, modifies `self` in place. If False, returns a new `SNPObject` with corrected + flips. Default is False. Returns: - Optional[SNPObject]: - A new SNPObject with corrected variant flips if `inplace=False`. + **Optional[SNPObject]**: + A new `SNPObject` with corrected flips if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Define complement mappings for nucleotides @@ -1137,25 +1135,28 @@ def remove_mismatching_variants( inplace: bool = False ) -> Optional['SNPObject']: """ - Remove mismatching variants between `self` and a reference `snpobj`. A mismatching variant - is one where either the `ref` or `alt` alleles differ between `self` and `snpobj` at - the same `chrom` and `pos` or `id`. + Remove variants from `self`, where reference (`variants_ref`) and/or alternate (`variants_alt`) alleles + do not match with a reference `snpobj`. Args: snpobj (SNPObject): The reference SNPObject to compare against. index_by (str, default='pos'): - Determines whether to match variants by 'pos' (chromosome-position) or 'id'. Default is 'pos'. - common_variants_intersection (Tuple[numpy.ndarray, numpy.ndarray]], optional): + Criteria for matching variants. Options: + - `'pos'`: Matches by chromosome and position (`variants_chrom`, `variants_pos`), e.g., 'chr1-12345'. + - `'id'`: Matches by variant ID alone (`variants_id`), e.g., 'rs123'. + - `'pos+id'`: Matches by chromosome, position, and ID (`variants_chrom`, `variants_pos`, `variants_id`), e.g., 'chr1-12345-rs123'. + Default is 'pos'. + common_variants_intersection (tuple of arrays, optional): Precomputed indices of common variants between `self` and the reference `snpobj`. If None, the intersection is computed within the function. inplace (bool, default=False): - If True, modifies `self` in place. If False, returns a new `SNPObject` without the + If True, modifies `self` in place. If False, returns a new `SNPObject` without mismatching variants. Default is False. Returns: - Optional[SNPObject]: - A new SNPObject without mismatching variants if `inplace=False`. + **Optional[SNPObject]:** + A new `SNPObject` without mismatching variants if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Get common variant indices if not provided @@ -1181,8 +1182,8 @@ def remove_mismatching_variants( def shuffle_variants(self, inplace: bool = False) -> Optional['SNPObject']: """ - Randomly shuffle the positions of variants in the SNPObject, ensuring all associated - data (e.g., `calldata_gt` and variant-specific attributes) maintain alignment. + Randomly shuffle the positions of variants in the SNPObject, ensuring that all associated + data (e.g., `calldata_gt` and variant-specific attributes) remain aligned. Args: inplace (bool, default=False): @@ -1190,8 +1191,8 @@ def shuffle_variants(self, inplace: bool = False) -> Optional['SNPObject']: shuffled variants. Default is False. Returns: - Optional[SNPObject]: - A new SNPObject without shuffled variant positions if `inplace=False`. + **Optional[SNPObject]:** + A new `SNPObject` without shuffled variant positions if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ # Generate a random permutation index for shuffling variant positions @@ -1220,16 +1221,16 @@ def shuffle_variants(self, inplace: bool = False) -> Optional['SNPObject']: def set_empty_to_missing(self, inplace: bool = False) -> Optional['SNPObject']: """ - Replace empty strings `''` with missing values `'.'` across various attributes in the SNPObject. + Replace empty strings `''` with missing values `'.'` in attributes of `self`. Args: inplace (bool, default=False): If True, modifies `self` in place. If False, returns a new `SNPObject` with empty - strings `''` replaced by missing values '.'. Default is False. + strings `''` replaced by missing values `'.'`. Default is False. Returns: - Optional[SNPObject]: - A new SNPObject with empty strings replaced if `inplace=False`. + **Optional[SNPObject]:** + A new `SNPObject` with empty strings replaced if `inplace=False`. If `inplace=True`, modifies `self` in place and returns None. """ if inplace: @@ -1265,9 +1266,9 @@ def set_empty_to_missing(self, inplace: bool = False) -> Optional['SNPObject']: snpobj.variants_id[snpobj.variants_id == ''] = '.' return snpobj - def save(self, filename: Union[str, pathlib.Path]) -> None: + def save(self, file: Union[str, pathlib.Path]) -> None: """ - Save the data stored in the `SNPObject` to a specified file. + Save the data stored in `self` to a specified file. The format of the saved file is determined by the file extension provided in the `file` argument. Supported formats are: @@ -1275,28 +1276,28 @@ def save(self, filename: Union[str, pathlib.Path]) -> None: - `.bed`: Binary PED (Plink) format. - `.pgen`: Plink2 binary genotype format. - `.vcf`: Variant Call Format. - - `.pkl`: Pickle format for saving the SNPObject in serialized form. + - `.pkl`: Pickle format for saving `self` in serialized form. Args: file (str or pathlib.Path): - The extension of the file determines the save format. Supported extensions: `.bed`, - `.pgen`, `.vcf`, `.pkl`. + The path to the file where the data will be saved. The extension of the file determines the save format. + Supported extensions: `.bed`, `.pgen`, `.vcf`, `.pkl`. """ - ext = pathlib.Path(filename).suffix.lower() + ext = pathlib.Path(file).suffix.lower() if ext == '.bed': - self.save_bed(filename) + self.save_bed(file) elif ext == '.pgen': - self.save_pgen(filename) + self.save_pgen(file) elif ext == '.vcf': - self.save_vcf(filename) + self.save_vcf(file) elif ext == '.pkl': - self.save_pickle(filename) + self.save_pickle(file) else: raise ValueError(f"Unsupported file extension: {ext}") - def save_bed(self, filename: Union[str, pathlib.Path]) -> None: + def save_bed(self, file: Union[str, pathlib.Path]) -> None: """ - Save the data stored in the `SNPObject` instance to a `.bed` file. + Save the data stored in `self` to a `.bed` file. Args: file (str or pathlib.Path): @@ -1304,12 +1305,12 @@ def save_bed(self, filename: Union[str, pathlib.Path]) -> None: If the provided path does not have this extension, it will be appended. """ from snputils.snp.io.write.bed import BEDWriter - writer = BEDWriter(snpobj=self, filename=filename) + writer = BEDWriter(snpobj=self, filename=file) writer.write() - def save_pgen(self, filename: Union[str, pathlib.Path]) -> None: + def save_pgen(self, file: Union[str, pathlib.Path]) -> None: """ - Save the data stored in the `SNPObject` instance to a `.pgen` file. + Save the data stored in `self` to a `.pgen` file. Args: file (str or pathlib.Path): @@ -1317,12 +1318,12 @@ def save_pgen(self, filename: Union[str, pathlib.Path]) -> None: If the provided path does not have this extension, it will be appended. """ from snputils.snp.io.write.pgen import PGENWriter - writer = PGENWriter(snpobj=self, filename=filename) + writer = PGENWriter(snpobj=self, filename=file) writer.write() - def save_vcf(self, filename: Union[str, pathlib.Path]) -> None: + def save_vcf(self, file: Union[str, pathlib.Path]) -> None: """ - Save the data stored in the `SNPObject` instance to a `.vcf` file. + Save the data stored in `self` to a `.vcf` file. Args: file (str or pathlib.Path): @@ -1330,12 +1331,12 @@ def save_vcf(self, filename: Union[str, pathlib.Path]) -> None: If the provided path does not have this extension, it will be appended. """ from snputils.snp.io.write.vcf import VCFWriter - writer = VCFWriter(snpobj=self, filename=filename) + writer = VCFWriter(snpobj=self, filename=file) writer.write() - def save_pickle(self, filename: Union[str, pathlib.Path]) -> None: + def save_pickle(self, file: Union[str, pathlib.Path]) -> None: """ - Save the `SNPObject` instance to a `.pkl` file. + Save `self` in serialized form to a `.pkl` file. Args: file (str or pathlib.Path): @@ -1343,7 +1344,7 @@ def save_pickle(self, filename: Union[str, pathlib.Path]) -> None: If the provided path does not have this extension, it will be appended. """ import pickle - with open(filename, 'wb') as file: + with open(file, 'wb') as file: pickle.dump(self, file) @staticmethod From 010fedd083682ad4a2f80e9cafa9cbbb25850a28 Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 10:33:09 -0500 Subject: [PATCH 06/11] Refine docstrings for TorchPCA and store X_new_ attribute --- snputils/processing/pca.py | 99 +++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 40 deletions(-) diff --git a/snputils/processing/pca.py b/snputils/processing/pca.py index 2ffafe2..4d3603b 100644 --- a/snputils/processing/pca.py +++ b/snputils/processing/pca.py @@ -33,28 +33,29 @@ def _svd_flip(u, v, u_based_decision=True): class TorchPCA: """ - A class to perform Principal Component Analysis (PCA) using PyTorch tensors. + A class for GPU-based Principal Component Analysis (PCA) with PyTorch tensors. This implementation leverages GPU acceleration to achieve significant performance improvements, being up to 25 times faster than `sklearn.decomposition.PCA` when running on a compatible GPU. """ - def __init__(self, n_components: int = None, fitting: str = 'reduced'): + def __init__(self, n_components: int = 2, fitting: str = 'reduced'): """ Args: - n_components (int, optional): + n_components (int, default=2): The number of principal components. If None, defaults to the minimum of `n_samples` and `n_snps`. fitting (str, default='reduced'): - The fitting approach to use for the SVD computation. Options: - - 'full': Full Singular Value Decomposition (SVD). - - 'reduced': Economy SVD. - - 'lowrank': Low-rank approximation, which provides a faster but approximate solution. - Default to 'reduced'. + The fitting approach for the SVD computation. Options: + - `'full'`: Full Singular Value Decomposition (SVD). + - `'reduced'`: Economy SVD. + - `'lowrank'`: Low-rank approximation, which provides a faster but approximate solution. + Default is 'reduced'. """ self.__n_components = n_components self.__fitting = fitting self.__n_components_ = None self.__components_ = None self.__mean_ = None + self.__X_new_ = None # Store transformed SNP data def __getitem__(self, key): """ @@ -82,7 +83,7 @@ def n_components(self) -> Optional[int]: Retrieve `n_components`. Returns: - int: The number of components to keep. If None, defaults to the minimum of `n_samples` and `n_snps`. + **int:** The number of principal components. """ return self.__n_components @@ -99,7 +100,8 @@ def fitting(self) -> str: Retrieve `fitting`. Returns: - str: The fitting approach to use for the SVD computation. + **str:** + The fitting approach for the SVD computation. """ return self.__fitting @@ -116,10 +118,9 @@ def n_components_(self) -> Optional[int]: Retrieve `n_components_`. Returns: - int: - The effective number of components retained after fitting. - It equals the parameter `n_components`, or the lesser value of - `n_snps` and `n_samples` if `n_components` is `None`. + **int:** + The effective number of components retained after fitting, + calculated as `min(self.n_components, min(n_samples, n_snps))`. """ return self.__n_components_ @@ -136,9 +137,8 @@ def components_(self) -> Optional[torch.Tensor]: Retrieve `components_`. Returns: - torch.Tensor: - The matrix of principal components obtained after fitting. - Each row represents a principal component vector. + **tensor of shape (n_components_, n_snps):** + Matrix of principal components, where each row is a principal component vector. """ return self.__components_ @@ -155,8 +155,8 @@ def mean_(self) -> Optional[torch.Tensor]: Retrieve `mean_`. Returns: - torch.Tensor: - The per-feature mean vector of the input data used for centering. + **tensor of shape (n_snps,):** + Per-feature mean vector of the input data used for centering. """ return self.__mean_ @@ -167,13 +167,31 @@ def mean_(self, x: torch.Tensor) -> None: """ self.__mean_ = x + @property + def X_new_(self) -> Optional[Union[torch.Tensor, np.ndarray]]: + """ + Retrieve `X_new_`. + + Returns: + **tensor of shape (n_samples, n_components_):** + The transformed SNP data projected onto the `n_components_` principal components. + """ + return self.__X_new_ + + @X_new_.setter + def X_new_(self, x: torch.Tensor) -> None: + """ + Update `X_new_`. + """ + self.__X_new_ = x + def copy(self) -> 'TorchPCA': """ - Create and return a copy of the current `TorchPCA` instance. + Create and return a copy of `self`. Returns: - TorchPCA: - A new instance of the current object. + **TorchPCA:** + A new instance of the current object. """ return copy.copy(self) @@ -183,8 +201,7 @@ def _fit(self, X: torch.Tensor) -> Tuple: Args: X (tensor of shape (n_samples, n_snps)): - The input SNP data used for fitting the model, where `n_samples` is the number of - samples and `n_snps` is the number of features. + Input SNP data used for fitting the model. Returns: Tuple: U, S, and Vt matrices from the SVD, where: @@ -225,52 +242,54 @@ def _fit(self, X: torch.Tensor) -> Tuple: def fit(self, X: torch.Tensor) -> 'TorchPCA': """ - Fit the model with `X`. + Fit the model to the input SNP data. Args: X (tensor of shape (n_samples, n_snps)): - The input SNP data used for fitting the model, where `n_samples` is the number of - samples and `n_snps` is the number of features. + The SNP data matrix to fit the model. Returns: - TorchPCA: - The fitted `TorchPCA` object instance. + **TorchPCA:** + The fitted instance of `self`. """ self._fit(X) return self def transform(self, X: torch.Tensor) -> torch.Tensor: """ - Apply dimensionality reduction on `X`. + Apply dimensionality reduction to the input SNP data using the fitted model. Args: X (tensor of shape (n_samples, n_snps)): - The data to transform, where `n_samples` is the number of samples and `n_snps` is the number of features. + The SNP data matrix to be transformed. Returns: - torch.Tensor of shape (n_samples, n_components_): - The transformed SNP data onto the `n_components_` principal components. + **tensor of shape (n_samples, n_components_):** + The transformed SNP data projected onto the `n_components_` principal components, + stored in `self.X_new_`. """ if self.components_ is None or self.mean_ is None: raise ValueError("The PCA model must be fitted before calling `transform`.") - return torch.matmul(X - self.mean_, self.components_.T) + self.X_new_ = torch.matmul(X - self.mean_, self.components_.T) + return self.X_new_ def fit_transform(self, X): """ - Fit the model with `X` and apply the dimensionality reduction on `X`. + Fit the model to the SNP data and apply dimensionality reduction on the same SNP data. Args: X (tensor of shape n_samples, n_snps): - The input SNP data used for fitting the model, where `n_samples` is the number of - samples and `n_snps` is the number of features. + The SNP data matrix used for both fitting and transformation. Returns: - torch.Tensor of shape (n_samples, n_components_): - The transformed SNP data onto the `n_components_` principal components. + **tensor of shape (n_samples, n_components_):** + The transformed SNP data projected onto the `n_components_` principal components, + stored in `self.X_new_`. """ U, S, _ = self._fit(X) - return U * S.unsqueeze(0) + self.X_new_ = U * S.unsqueeze(0) + return self.X_new_ class PCA: From e1c1f494331f724854d87d4d18ff721507240c87 Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 10:41:33 -0500 Subject: [PATCH 07/11] Refine docstrings for PCA and store n_components_, components_, and mean_ attributes. Remove repeated properties and setters. Solve bug in SNP_PCA.ipynb --- demos/SNP_PCA.ipynb | 8 +- demos/TorchPCA.ipynb | 14 +-- snputils/processing/pca.py | 230 +++++++++++++++++++++++-------------- 3 files changed, 157 insertions(+), 95 deletions(-) diff --git a/demos/SNP_PCA.ipynb b/demos/SNP_PCA.ipynb index 8fa3404..3f1021b 100644 --- a/demos/SNP_PCA.ipynb +++ b/demos/SNP_PCA.ipynb @@ -71,7 +71,7 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA94AAAMWCAYAAAAH1l7yAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAABllklEQVR4nO3deZxWZf0//tcNDCAKg4iAC5tZmlvivqSiIppmWWZ+08wtNUMtt1wqlyz3TDMtLcPyU2m2uGaCu+aCYKho7qIFAq6MgsLAnN8fPpifyDZwz2GY4fl8PObR3Odc17ne99wXk68551ynUhRFEQAAAKAU7Vq6AAAAAGjLBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAAStShpQtoKxoaGjJx4sR07do1lUqlpcsBAACgREVR5N13383qq6+edu0Wfk5b8G4mEydOTN++fVu6DAAAAJai//73v1lzzTUX2kbwbiZdu3ZN8uEPvVu3bi1cDUtbfX19RowYkaFDh6ampqaly6GNMK8og3lFGcwrymBeUYbmnFd1dXXp27dvYxZcGMG7mcy5vLxbt26C93Kovr4+Xbp0Sbdu3fwfA83GvKIM5hVlMK8og3lFGcqYV0251djiagAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFCiDi1dACxPpk6vz7iJUzP1/frUrlCTDVavTW2XmpYuCwAAKJHgDUvJUxOn5o6np6ShKBq3jR7/doas1yvrr17bgpUBAABlcqk5LAVTp9fPE7qTpKEocsfTUzJ1en0LVQYAAJRN8IalYNzEqfOE7jkaiiLjJk5dyhUBAABLi+ANS8HU9xd+RrtuEfsBAIDWS/CGpaB2hYUvoNZtEfsBAIDWS/CGpWCD1WvTrlKZ7752lUo2sLgaAAC0WYI3LAW1XWoyZL1e84TvdpVKdlmvt0eKAQBAG+ZxYrCUrL96bdbs3iXjJk5N3fv16eY53gAAsFwQvGEpqu1Sk23X7tnSZQAAAEuRS80BAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEy13wPvfcc1OpVPLd7363cdsHH3yQYcOGZZVVVslKK62UvffeO5MnT265IgEAAGgzlqvg/eijj+aKK67IRhttNNf2Y489NjfffHOuv/763HvvvZk4cWK+/OUvt1CVAAAAtCXLTfB+7733sv/+++fXv/51Vl555cbtU6dOzVVXXZWLLrooO+20UzbddNMMHz48Dz74YB5++OEWrBgAAIC2YLkJ3sOGDcsee+yRIUOGzLV9zJgxqa+vn2v7uuuum379+uWhhx5a2mUCAADQxnRo6QKWhmuvvTaPPfZYHn300Xn2TZo0KR07dkz37t3n2t67d+9MmjRpgcecMWNGZsyY0fi6rq4uSVJfX5/6+vrmKZxWY85n7rOnOZlXlMG8ogzmFWUwryhDc86rxTlGmw/e//3vf/Od73wnI0eOTOfOnZvtuOecc07OPPPMebaPGDEiXbp0abZxaF1GjhzZ0iXQBplXlMG8ogzmFWUwryhDc8yr6dOnN7ltpSiKouoRl2E33HBDvvSlL6V9+/aN22bPnp1KpZJ27drl9ttvz5AhQ/L222/Pdda7f//++e53v5tjjz12vsed3xnvvn375o033ki3bt1Kez8sm+rr6zNy5MjssssuqampaelyaCPMK8pgXlEG84oymFeUoTnnVV1dXXr27JmpU6cuMgO2+TPeO++8c5588sm5th188MFZd911c9JJJ6Vv376pqanJnXfemb333jtJ8uyzz+bVV1/N1ltvvcDjdurUKZ06dZpne01NjV8MyzGfP2UwryiDeUUZzCvKYF5RhuaYV4vTv80H765du2aDDTaYa9uKK66YVVZZpXH7oYcemuOOOy49evRIt27dcvTRR2frrbfOVltt1RIlAwAA0Ia0+eDdFD/72c/Srl277L333pkxY0Z23XXXXH755S1dFgAAAG3Achm877nnnrled+7cOZdddlkuu+yylikIAACANmu5eY43AAAAtATBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoEQdWroAyjd1en3GTZyaqe/Xp3aFmmywem1qu9S0dFkAAADLBcG7jXtq4tTc8fSUNBRF47bR49/OkPV6Zf3Va1uwMgAAgOWDS83bsKnT6+cJ3UnSUBS54+kpmTq9voUqAwAAWH4I3m3YuIlT5wndczQURcZNnLqUKwIAAFj+CN5t2NT3F35Gu24R+wEAAKie4N2G1a6w8AXUui1iPwAAANUTvNuwDVavTbtKZb772lUq2cDiagAAAKUTvNuw2i41GbJer3nCd7tKJbus19sjxQAAAJYCjxNr49ZfvTZrdu+ScROnpu79+nTzHG8AAIClSvBeDtR2qcm2a/ds6TIAAACWSy41BwAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJSozQfvc845J5tvvnm6du2aXr16Za+99sqzzz47V5sPPvggw4YNyyqrrJKVVlope++9dyZPntxCFQMAANCWtPngfe+992bYsGF5+OGHM3LkyNTX12fo0KGZNm1aY5tjjz02N998c66//vrce++9mThxYr785S+3YNUAAAC0FR1auoCy/fOf/5zr9dVXX51evXplzJgx2X777TN16tRcddVV+eMf/5iddtopSTJ8+PB8+tOfzsMPP5ytttqqJcoGAACgjWjzwfvjpk6dmiTp0aNHkmTMmDGpr6/PkCFDGtusu+666devXx566KEFBu8ZM2ZkxowZja/r6uqSJPX19amvry+rfJZRcz5znz3NybyiDOYVZTCvKIN5RRmac14tzjGWq+Dd0NCQ7373u9l2222zwQYbJEkmTZqUjh07pnv37nO17d27dyZNmrTAY51zzjk588wz59k+YsSIdOnSpVnrpvUYOXJkS5dAG2ReUQbzijKYV5TBvKIMzTGvpk+f3uS2y1XwHjZsWMaNG5cHHnig6mOdcsopOe644xpf19XVpW/fvhk6dGi6detW9fFpXerr6zNy5MjssssuqampaelyaCPMK8pgXlEG84oymFeUoTnn1ZyrnptiuQneRx11VG655Zbcd999WXPNNRu39+nTJzNnzsw777wz11nvyZMnp0+fPgs8XqdOndKpU6d5ttfU1PjFsBzz+VMG84oymFeUwbyiDOYVZWiOebU4/dv8quZFUeSoo47K3//+99x1110ZOHDgXPs33XTT1NTU5M4772zc9uyzz+bVV1/N1ltvvbTLBQAAoI1p82e8hw0blj/+8Y+58cYb07Vr18b7tmtra7PCCiuktrY2hx56aI477rj06NEj3bp1y9FHH52tt97aiuYAAABUrc0H71/+8pdJksGDB8+1ffjw4TnooIOSJD/72c/Srl277L333pkxY0Z23XXXXH755Uu5UgAAANqiNh+8i6JYZJvOnTvnsssuy2WXXbYUKgIAAGB50ubv8QYAAICWJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEHZb2gLNnz86ECROSJP369VvawwMAAMBStdhnvKdPn54f//jH2XjjjdO1a9d07949W2+9dS6++OJ88MEHi+z/zDPPZMCAAVlrrbWWqGAAAABoTRbrjPd///vf7LLLLnn++eeTJEVRJElGjRqVUaNG5eKLL87vfve77LDDDos81py+AAAA0JY1+Yz37Nmz85WvfCXPPfdciqJITU1NNttss2y44YZp3759iqLIq6++mp133jk//elPy6wZAAAAWo0mB++//OUvefTRR1OpVPLVr341EydOzKhRo/L4449nwoQJOf7449OhQ4c0NDTke9/7Xk4++eQy6wYAAIBWocnB+9prr02SbL755vnTn/6UHj16NO5bddVVc8EFF+T+++/PmmuumaIocsEFF+Rb3/pW81cMAAAArUiTg/fo0aNTqVRyzDHHpFKpzLfNlltumUcffTSbbLJJiqLIr3/96+y///5paGhotoIBAACgNWly8H7jjTeSJJ/+9KcX2q5379655557Mnjw4BRFkWuvvTZ777136uvrq6sUAAAAWqEmB+927T5s2pRHhq200kq57bbb8vnPfz5FUeSmm27K5z//+bz//vtLXikAAAC0Qk0O3mussUaSND5KbFE6deqUv//979l3331TFEXuuOOO7Lrrrqmrq1uySgEAAKAVanLw3mijjZIkd955Z5MP3r59+/zxj3/MoYcemqIo8q9//Sv77rvv4lcJAAAArVSTg/ece7b//ve/Z9q0aU0eoFKp5Ne//nW++93vpiiKTJgwYYkKBQAAgNaoycF7zz33TKVSybRp0/LLX/5ysQe66KKLctppp6UoisXuCwAAAK1Vh6Y27N+/f77//e/ntddey5tvvrlEg51xxhlZZZVV8re//W2J+gMAAEBr0+TgnSQ/+tGPqh7w6KOPztFHH131cQAAAKA1aPKl5gAAAMDiE7wBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEVQXvnXbaKTvvvHNeeeWVJveZOHFiYz8AAABo6zpU0/mee+5JpVLJtGnTmtzn/fffb+wHAAAAbZ1LzQEAAKBESz14zzk73rlz56U9NAAAACx1Sz1433bbbUmSNddcc2kPDQAAAEvdYt3jfcghh8x3+w9+8IN07959oX1nzJiRF198MY8++mgqlUp22GGHxRkaAAAAWqXFCt5XX331PIuiFUWRG2+8sUn9i6JIkvTo0SOnnHLK4gwNAAAArdJiBe9+/frNFbxfeeWVVCqVrLbaaqmpqVlgv0qlks6dO2e11VbLNttskyOPPDKrr776klcNAAAArcRiBe/x48fP9bpduw9vER8xYkTWW2+9ZisKAAAA2oqqnuO9/fbbp1KpZMUVV2yuegAAAKBNqSp433PPPc1UBgAAALRNS/1xYgAAALA8qeqM9/zU1dXl3XffzezZsxfZtl+/fs09PAAAACxTmiV4jxw5MpdffnkeeOCBvPXWW03qU6lUMmvWrOYYHgAAAJZZVQfvY445JpdddlmS//853QAAAMCHqgref/zjH/OLX/wiSdK5c+fstdde2XTTTdOjR4/GR40BAADA8qyq4H3FFVckSfr27Zu77rorn/jEJ5qlKAAAAGgrqjot/cQTT6RSqeT0008XugEAAGA+qgre9fX1SZJBgwY1SzEAAADQ1lQVvAcMGJAkee+995qjFgAAAGhzqgreX/7yl5Mkd955Z7MUAwAAAG1NVcH7+OOPT79+/XLxxRfnmWeeaa6aAAAAoM2oKnjX1tbm9ttvT+/evbPNNtvk8ssvz9tvv91ctQEAAECrV9XjxNZaa60kyfTp0/POO+/k6KOPzjHHHJOePXumS5cuC+1bqVTy4osvVjM8AAAALPOqCt7jx4+f63VRFCmKIlOmTFlk30qlUs3QAAAA0CpUFbwPPPDA5qoDAAAA2qSqgvfw4cObqw4AAABok6paXA0AAABYOMEbAAAASlTVpeYf9/7772fMmDGZNGlSpk+fnr322ivdunVrziEAAACgVWmW4P3f//43p556aq6//vrU19c3bt9ss82y3nrrNb6+6qqrcsUVV6S2tjYjRoywsjkAAABtXtWXmj/yyCMZNGhQ/vjHP2bmzJmNjxSbnz333DNPPPFE7rrrrowYMaLaoQEAAGCZV1Xwfuedd/LFL34xb731Vvr06ZPLL788Tz755ALb9+rVK5/73OeSJLfeems1QwMAAECrUNWl5j//+c8zZcqU9OzZMw899FD69eu3yD5DhgzJjTfemFGjRlUzNAAAALQKVZ3xvvnmm1OpVHLcccc1KXQnyfrrr58kefHFF6sZGgAAAFqFqoL3Cy+8kCTZfvvtm9xn5ZVXTpLU1dVVMzQAAAC0ClUF7w8++CBJUlNT0+Q+06ZNS5KssMIK1QwNAAAArUJVwbtXr15JkpdffrnJfcaOHZskWX311asZGgAAAFqFqoL3lltumSS57bbbmtS+KIr8+te/TqVSyXbbbVfN0AAAANAqVBW8999//xRFkT/84Q+NZ7IX5vjjj8/jjz+eJDnwwAOrGRoAAABahaqC9xe/+MXsuOOOmTVrVnbeeef88pe/zJQpUxr3z5o1KxMnTsz111+f7bbbLpdcckkqlUq+/OUvZ5tttqm6eAAAAFjWVfUc7yT561//mp133jn//ve/c9RRR+Woo45KpVJJkgwaNGiutkVRZKuttsrVV19d7bAAAADQKlR1xjtJunfvnoceeiinnHJKunXrlqIo5vu1wgor5Hvf+17uueeerLjiis1ROwAAACzzqj7jnSQdO3bMT37yk5x66qm59957M3r06EyZMiWzZ8/OKquskkGDBmXIkCGpra1tjuEAAACg1WiW4D3HiiuumN133z277757cx4WAAAAWq2qLzUHAAAAFkzwBgAAgBI126Xmb775Zh566KG89NJLeffddzN79uxF9jnttNOaa3gAAABYJlUdvKdMmZJjjz02f/nLXzJr1qzF6it4AwAA0NZVdan522+/nc9+9rO59tprU19fv8BHiS3oa1lz2WWXZcCAAencuXO23HLLjBo1qqVLAgAAoJWrKnife+65eeGFF1IURYYOHZp//vOfef311zN79uw0NDQs8mtZct111+W4447L6aefnsceeyyf+cxnsuuuu2bKlCktXRoAAACtWFXB+8Ybb0ylUsnnP//5/POf/8zQoUOzyiqrpFKpNFd9S81FF12Uww47LAcffHDWW2+9/OpXv0qXLl3y29/+tqVLAwAAoBWr6h7vV199NUkybNiwZimmpcycOTNjxozJKaec0ritXbt2GTJkSB566KH59pkxY0ZmzJjR+Lquri5JUl9fn/r6+nILZpkz5zP32dOczCvKYF5RBvOKMphXlKE559XiHKOq4L3SSitlxowZ6d27dzWHaXFvvPFGZs+ePc/76N27d5555pn59jnnnHNy5plnzrN9xIgR6dKlSyl1suwbOXJkS5dAG2ReUQbzijKYV5TBvKIMzTGvpk+f3uS2VQXvDTfcMPfcc09eeeWVbLzxxtUcqtU55ZRTctxxxzW+rqurS9++fTN06NB069atBSujJdTX12fkyJHZZZddUlNT09Ll0EaYV5TBvKIM5hVlMK8oQ3POqzlXPTdFVcH7iCOOyN13351rrrkmX/ziF6s5VIvq2bNn2rdvn8mTJ8+1ffLkyenTp898+3Tq1CmdOnWaZ3tNTY1fDMsxnz9lMK8og3lFGcwrymBeUYbmmFeL07+qxdW++tWvZv/998/f//73nHvuudUcqkV17Ngxm266ae68887GbQ0NDbnzzjuz9dZbt2BlAAAAtHZVnfG+7777cuihh+bll1/O97///fztb3/Lfvvtl3XXXbdJ9zlvv/321QzfrI477rgceOCB2WyzzbLFFlvk4osvzrRp03LwwQe3dGkAAAC0YlUF78GDB8/16LAxY8ZkzJgxTepbqVQya9asaoZvVvvuu29ef/31nHbaaZk0aVI23njj/POf/2z1C8cBAADQsqoK3klSFEVz1LFMOOqoo3LUUUe1dBkAAAC0IVUF77vvvru56gAAAIA2qargvcMOOzRXHQAAAJCp0+szbuLUTH2/PrUr1GSD1WtT26V1r2xf9aXmAAAA0Byemjg1dzw9JQ0fuaV59Pi3M2S9Xll/9doWrKw6VT1ODAAAAJrD1On184TuJGkoitzx9JRMnV7fQpVVr1nPeI8ZMyZ33HFHxo0bl7feeitJ0qNHj2ywwQYZMmRINt100+YcDgAAgDZi3MSp84TuORqKIuMmTs22a/dcylU1j2YJ3k8++WQOP/zwjBo1aoFtTj311Gy55Za54oorsuGGGzbHsAAAALQRU99f+BntukXsX5ZVfan5HXfckS222CKjRo1KURQpiiIdOnRI796907t373To0KFx+8MPP5wtttgid955Z3PUDgAAQBtRu8LCF1Drtoj9y7Kqgvcbb7yRffbZJzNmzEilUsk3v/nNPPLII5k2bVomTpyYiRMnZvr06Rk1alQOO+ywtG/fPjNmzMg+++yTN998s7neAwAAAK3cBqvXpl2lMt997SqVbLC8Lq52ySWXZOrUqenYsWNuvfXWXHnlldl8883TocP/fwV7+/bts9lmm+WKK67IrbfempqamkydOjWXXHJJ1cUDAADQNtR2qcmQ9XrNE77bVSrZZb3erfqRYlUF71tvvTWVSiVHHXVUdt1110W2Hzp0aI4++ugURZFbb721mqEBAABoY9ZfvTYHbTMgWwzskXX7dM0WA3vkoG0GZL3Vu7V0aVWpKni//PLLSZIvfOELTe4zp+1LL71UzdAAAAC0QbVdarLt2j3zuQ1Xy7Zr92zVZ7rnqCp4f/DBB0mSFVdcscl95rSdMWNGNUMDAABAq1BV8O7Tp0+S5N///neT+8xp27t372qGBgAAgFahquC93XbbpSiKnHvuuamrq1tk+3fffTfnnXdeKpVKtttuu2qGBgAAgFahquB9xBFHJPnwXu/tt98+o0ePXmDb0aNHZ4cddsiLL744V18AAABoyzosusmCbbvttvn2t7+dyy+/PE8++WS23HLLrL/++tlyyy3Tq1evVCqVTJ48OY888kieeuqpxn7f/va3s+2221ZdPAAAACzrqgreSXLppZemS5cuueiii9LQ0JBx48bNFbKTpCiKJEm7du1ywgkn5Nxzz612WAAAAGgVqrrUPEkqlUrOP//8jB07NkceeWQ++clPpiiKub4++clP5sgjj8zYsWMb7/EGAACA5UHVZ7zn2GCDDXLZZZclSWbOnJm33347SbLyyiunY8eOzTUMAAAAtCrNFrw/qmPHjh4XBgAAAGmGS80BAACABWu2M96zZ8/OjTfemDvuuCNPPvlk3nrrrSRJjx49ssEGG2TIkCH54he/mA4dSjnJDgAAAMukZknBN910U4466qhMmDChcduclcwrlUoefPDBXHnllVlttdXyi1/8InvttVdzDAsAAADLvKovNb/kkkvypS99KRMmTGgM2wMGDMhWW22VrbbaKgMGDEjyYRCfOHFi9t5771x88cXVDgsAAACtQlXB+5FHHsnxxx+foijStWvXnHfeeZk8eXJefPHFPPjgg3nwwQfz4osvZvLkyTnvvPNSW1uboihy4okn5pFHHmmu9wAAAADLrKqC90UXXZSGhobU1tbmwQcfzIknnpiePXvO065nz5458cQT8+CDD6a2tjYNDQ256KKLqhkaAAAAWoWqgvf999+fSqWSk046Keutt94i23/605/OSSedlKIoct9991UzNAAAALQKVQXvt99+O0my4447NrnPnLbvvPNONUMDAABAq1BV8F5ttdVapC8AAAC0FlUF7yFDhiRJ7r333ib3ueeee5IkO+20UzVDAwAAQKtQVfA+/vjjs8IKK+Tcc8/Nc889t8j2zz33XM4777ysuOKKOfHEE6sZGgAAAFqFqoL3Ouusk7/85S9Jkq222ioXX3xx3nrrrXnavf3227nkkkuyzTbbJEn+/Oc/Z5111qlmaAAAAGgVOlTTec7l4quuumqef/75HH/88TnhhBMycODA9OrVK5VKJZMnT87LL7+coiiSJGuvvXYuuOCCXHDBBfM9ZqVSyZ133llNWQAAALDMqCp433PPPalUKo2vi6JIURR58cUX8+KLL863zwsvvJAXXnihMYjPUalUUhTFXMcDAACA1q6q4L399tsLygAAALAQVZ/xBgAAABasqsXVAAAAgIUTvAEAAKBEgjcAAACUqKp7vD+qoaEhTz/9dF566aW8++67mT179iL7fOMb32iu4QEAAGCZVHXwnj59en784x/nN7/5Td58880m96tUKoI3AAAAbV5Vwfu9997LjjvumMcee2ye53IDAAAAVQbvH//4xxkzZkySZKuttsrhhx+ez3zmM+nevXvatXP7OAAAAFQVvP/yl7+kUqlk9913z4033ihsAwAAwMdUlZQnTJiQJDnmmGOEbgAAAJiPqtJyr169kiQ9e/ZslmIAAACgrakqeG+xxRZJkmeffbZZigEAAIC2pqrgfeyxxyZJfvGLX1jVHAAAAOajquC9zTbb5LzzzsuDDz6Y//f//l/eeeedZioLAAAA2oaqVjVPkhNOOCGf+MQncthhh6Vv377ZZZdd8qlPfSpdunRZZN/TTjut2uEBAABgmVZ18J4yZUr+/ve/Z+rUqWloaMiNN97Y5L6CNwAAAG1dVcH7zTffzPbbb5/nn3/ePd4AAAAwH1Xd43322WfnueeeS1EU+cpXvpK77rorb775ZmbPnp2GhoZFfgEAAEBbV9UZ75tuuimVSiVf//rX87vf/a65agIAAIA2o6oz3hMmTEiSHHLIIc1SDAAAALQ1VQXvnj17Jkm6du3aLMUAAABAW1NV8N5uu+2SJOPGjWuWYgAAAKCtqSp4H3/88ampqcmFF16YDz74oLlqAgAAgDajquC9ySab5De/+U2ee+65DB06NM8991xz1QUAAABtQlWrms9ZVG299dbLAw88kPXWWy8bbbRRPvWpT6VLly4L7VupVHLVVVdVMzwAAAAs86oK3ldffXUqlUqSD4N0Q0NDHn/88Tz++OML7VcUheANAADAcqGq4N2vX7/G4A0AAADMq6rgPX78+GYqAwAAANqmqhZXAwAAABZO8AYAAIASVXWp+YLMmjUrb7/9dpJk5ZVXTocOpQwDAAAAy7xmO+P9n//8J0cffXQ+/elPp3PnzunTp0/69OmTzp0759Of/nSOOeaYPP300801HAAAALQKzRK8TznllGy00Ua5/PLL8+yzz6ahoSFFUaQoijQ0NOTZZ5/NZZddls985jM59dRTm2NIAAAAaBWqvgb86KOPzuWXX56iKJIkn/70p7PlllumT58+SZJJkyZl1KhRefrppzN79uycd955mTZtWi655JJqhwYAAIBlXlXB+1//+lcuu+yyVCqVrLfeernyyiuzzTbbzLftQw89lG9961t58skn84tf/CL77rvvAtsCAABAW1HVpeZXXHFFkmTgwIH517/+tdAgvfXWW+e+++7LWmutlST51a9+Vc3QAAAA0CpUFbzvv//+VCqVnHzyyamtrV1k+9ra2px00kkpiiL3339/NUMDAABAq1BV8J40aVKSZNCgQU3us8kmmyRJJk+eXM3QAAAA0CpUFbw7d+6cJJk2bVqT+8xp26lTp2qGBgAAgFahquA9cODAJMnNN9/c5D5z2s651xsAAADasqqC9+67756iKHLppZfmzjvvXGT7u+++O5deemkqlUp23333aoYGAACAVqGq4P3d73433bp1S319fT73uc/lqKOOymOPPZaGhobGNg0NDXnsscdy1FFHZbfddsvMmTPTrVu3fPe73622dgAAAFjmVfUc7549e+bPf/5zvvCFL2TmzJn55S9/mV/+8pfp2LFjevTokUqlkjfffDMzZ85MkhRFkY4dO+b666/PKqus0ixvAAAAAJZlVZ3xTpKhQ4fm4YcfzmabbZaiKFIURWbMmJHXXnstEydOzIwZMxq3b7bZZnnkkUcyZMiQ5qgdAAAAlnlVnfGeY+ONN86oUaPy6KOP5o477si4cePy1ltvJUl69OiRDTbYIEOGDMnmm2/eHMMBAABAq9EswXuOzTffXLgGAACAj6j6UnMAAABgwRbrjPeMGTPy7LPPJklqa2vTv3//Jvd95ZVXMnXq1CTJpz/96dTU1CzO0AAAANAqLdYZ77POOiuDBg3KFltskf/973+LNdD//ve/bL755hk0aFDOO++8xeoLAAAArVWTg/fUqVPzs5/9LElyyimnZNttt12sgbbddtuceuqpKYoi559/ft57773FqxQAAABaoSYH7+uuuy7vv/9+VllllZxwwglLNNiJJ56YVVddNdOmTct11123RMcAAACA1qTJwXvkyJGpVCr50pe+lBVXXHGJBuvSpUv23nvvFEWRESNGLNExAAAAoDVpcvD+97//nSQZMmRIVQPutNNOSZLHHnusquMAAABAa9Dk4P36668nSdZcc82qBlxjjTWSJFOmTKnqOAAAANAaNDl4z5gxI0nSsWPHqgac03/mzJlVHQcAAABagyYH7549eyZJJk+eXNWAc850r7LKKlUdBwAAAFqDJgfvvn37JkkefPDBqgb817/+NdfxAAAAoC1rcvDecccdUxRF/vSnP2XWrFlLNFh9fX3++Mc/plKpZMcdd1yiYwAAAEBr0uTgvffeeydJxo8fnx//+MdLNNhPfvKTjB8/fq7jAQAAQFvW5OC96aabZo899khRFDnrrLNyzjnnpCiKJg909tln50c/+lEqlUr22GOPbLrppktUMAAAALQmTQ7eSXLZZZeld+/eSZIf/OAH2WyzzfK73/2u8VFjH/f666/n6quvzqabbpof/vCHSZLevXvnsssuq7JsAAAAaB06LE7jfv365eabb86ee+6ZyZMnZ+zYsTnkkEOSfPh87l69emXFFVfMtGnTMnny5EycOLGxb1EU6d27d26++WYLqwEAALDcWKzgnSSbbbZZxo4dm8MPPzw333xz4/YJEyZkwoQJc7X96KXoX/jCF3LFFVc0njEHAACA5cFiB+/kw8vFb7zxxjz11FMZPnx47r333jzxxBOpr69vbFNTU5ONNtooO+ywQw466KBssMEGzVY0AAAAtBZLFLznWH/99XPhhRc2vn733Xfz7rvvpmvXrunatWvVxQEAAEBrV1Xw/jiBGwAAAOa2WKuaAwAAAItH8AYAAIAStengPX78+Bx66KEZOHBgVlhhhXziE5/I6aefnpkzZ87V7oknnsh2222Xzp07p2/fvjn//PNbqGIAAADamma9x3tZ88wzz6ShoSFXXHFF1l577YwbNy6HHXZYpk2b1rgoXF1dXYYOHZohQ4bkV7/6VZ588skccsgh6d69ew4//PAWfgcAAAC0dm06eO+2227ZbbfdGl+vtdZaefbZZ/PLX/6yMXj/4Q9/yMyZM/Pb3/42HTt2zPrrr5+xY8fmoosuErwBAACoWpu+1Hx+pk6dmh49ejS+fuihh7L99tunY8eOjdt23XXXPPvss3n77bdbokQAAADakDZ9xvvjXnjhhVx66aVzPXt80qRJGThw4Fztevfu3bhv5ZVXnu+xZsyYkRkzZjS+rqurS5LU19envr6+uUtnGTfnM/fZ05zMK8pgXlEG84oymFeUoTnn1eIco1UG75NPPjnnnXfeQtv85z//ybrrrtv4esKECdltt92yzz775LDDDqu6hnPOOSdnnnnmPNtHjBiRLl26VH18WqeRI0e2dAm0QeYVZTCvKIN5RRnMK8rQHPNq+vTpTW5bKYqiqHrEpez111/Pm2++udA2a621VuPl4xMnTszgwYOz1VZb5eqrr067dv//Ffbf+MY3UldXlxtuuKFx2913352ddtopb7311mKd8e7bt2/eeOONdOvWrYp3R2tUX1+fkSNHZpdddklNTU1Ll0MbYV5RBvOKMphXlMG8ogzNOa/q6urSs2fPTJ06dZEZsFWe8V511VWz6qqrNqnthAkTsuOOO2bTTTfN8OHD5wrdSbL11lvn+9//furr6xt/8CNHjsw666yzwNCdJJ06dUqnTp3m2V5TU+MXw3LM508ZzCvKYF5RBvOKMphXlKE55tXi9G/Ti6tNmDAhgwcPTr9+/XLhhRfm9ddfz6RJkzJp0qTGNvvtt186duyYQw89NE899VSuu+66XHLJJTnuuONasHIAAADaiiad8V5rrbWafeBKpZIXX3yx2Y/7USNHjswLL7yQF154IWuuueZc++ZcYV9bW5sRI0Zk2LBh2XTTTdOzZ8+cdtppHiUGAABAs2hS8B4/fnyzD1ypVJr9mB930EEH5aCDDlpku4022ij3339/6fUAAACw/GlS8D7wwAPLrgMAAADapCYF7+HDh5ddBwAAALRJbXpxNQAAAGhpgjcAAACUSPAGAACAEjXpHu/FMX78+Lzxxht5//33Gx/ZtSDbb799cw8PAAAAy5RmCd7PPvtszj777Nx0002pq6trUp9KpZJZs2Y1x/AAAACwzKo6eN9www3Zf//988EHHyzyDDcAAAAsb6oK3v/973/z9a9/Pe+//37WWGONnHjiienSpUsOP/zwVCqV3HHHHXnrrbcyevToXHPNNZk4cWI++9nP5owzzkj79u2b6z0AAADAMquq4P3zn/8806dPT9euXfPII49k9dVXz1NPPdW4f8cdd0yS7L333jnttNNy6KGH5rrrrstVV12VP/zhD9VVDgAAAK1AVaua33HHHalUKvn2t7+d1VdffaFtV1hhhfzf//1fBg0alGuvvTZ//etfqxkaAAAAWoWqgvf48eOTJNtss03jtkql0vj9xxdPa9euXY455pgURZHf/va31QwNAAAArUJVwXvatGlJkr59+zZu69KlS+P3U6dOnafP+uuvnyR5/PHHqxkaAAAAWoWqgndtbW2S5IMPPmjctsoqqzR+/+KLL87TZ04Yf+ONN6oZGgAAAFqFqoL3OuuskyR56aWXGrd17do1/fv3T5KMGDFinj4jR45MknTv3r2aoQEAAKBVqCp4b7311kmShx9+eK7tn//851MURS644ILcfffdjdv//Oc/55JLLkmlUsm2225bzdAAAADQKlQVvHffffcURZG//e1vmT17duP2Oc/zfu+99zJkyJCsuuqq6dq1a772ta/lgw8+SLt27XLiiSdWXTwAAAAs66oK3oMHD87pp5+egw8+OBMmTGjc3q9fv1x//fWpra1NURR58803M23atBRFkU6dOuXXv/51ttpqq6qLBwAAgGVdh2o6VyqVnH766fPd97nPfS7PP/98/vKXv+Spp57KrFmz8slPfjJf/epXs8Yaa1QzLAAAALQaVQXvRVlllVVyxBFHlDkEAAAALNOqutQcAAAAWLhmP+NdFEVeeumlvPXWW0mSHj16ZK211kqlUmnuoQAAAGCZ12zB+5///Gcuv/zy3HPPPZk2bdpc+7p06ZLBgwfn29/+dj73uc8115AAAACwzKv6UvPp06dn7733zh577JFbb7017733XoqimOtr2rRp+cc//pHPf/7z+dKXvjRPMAcAAIC2qqoz3g0NDdl9991z//33pyiK1NTUZOjQodliiy3Su3fvJMnkyZPz6KOPZsSIEZk5c2Zuuumm7L777rnnnntcfg4AAECbV1XwvuKKK3LfffelUqlk1113zW9+85sFPipswoQJOeyww/LPf/4zDzzwQH71q1/lyCOPrGZ4AAAAWOZVdan57373uyTJ5ptvnltvvXWhz+deY401cvPNN2eLLbZIURSNfQEAAKAtqyp4/+c//0mlUsmxxx6bdu0Wfaj27dvnuOOOa+wLAAAAbV1VwXvOPdqf+tSnmtznk5/85Fx9AQAAoC2rKnh/4hOfSJJMmTKlyX3mtJ3TFwAAANqyqoL31772tRRFkd///vdN7vP73/8+lUol++67bzVDAwAAQKtQVfA+5phjsskmm+Taa6/N+eefv8j2F1xwQf70pz9l0KBB+e53v1vN0AAAANAqVPU4sUmTJuU3v/lNjjjiiJxyyin505/+lAMPPDCbb755evXqlUql0vgc72uuuSZjx47N5ptvniuvvDKTJk1a4HH79etXTVkAAACwzKgqeA8YMGCuRdKeeOKJHH/88QvtM3r06GyyySYL3F+pVDJr1qxqygIAAIBlRlXBO0mKomiOOgAAAKBNqip4Dx8+vLnqAAAAgDapquB94IEHNlcdAAAA0CZVtao5AAAAsHCCNwAAAJRI8AYAAIASNeke7x/96EeN35922mnz3b4kPnosAAAAaIuaFLzPOOOMxud1fzQsf3T7khC8AQAAaOuavKr5gp7X7TneAAAAsGBNCt4NDQ2LtR0AAAD4kMXVAAAAoESCNwAAAJRI8AYAAIASVRW8J02alEMOOSSHHHJIJkyYsMj2EyZMyCGHHJJDDz00b731VjVDAwAAQKtQVfC+5pprcvXVV2fs2LFZY401Ftl+jTXWyNixY3P11Vfn//7v/6oZGgAAAFqFqoL3iBEjUqlU8pWvfKXJffbdd98URZHbbrutmqEBAACgVagqeI8bNy5JssUWWzS5z2abbZYkeeKJJ6oZGgAAAFqFqoL3m2++mSRZddVVm9ynZ8+ec/UFAACAtqyq4L3SSislSaZOndrkPnV1dUmSjh07VjM0AAAAtApVBe8111wzSfLQQw81uc+//vWvJGnSYmwAAADQ2lUVvAcPHpyiKHLppZc2nslemLq6uvziF79IpVLJ4MGDqxkaAAAAWoWqgvcRRxyRSqWS1157LXvssUcmT568wLaTJk3KHnvskYkTJ6ZSqeSII46oZmgAAABoFTpU03n99dfPd77znVx88cV58MEHs/baa2fffffNdtttl9VWWy1J8tprr+W+++7Ln//850yfPj2VSiXDhg3Lxhtv3Bz1AwAAwDKtquCdJBdeeGGmTp2a4cOHZ9q0aRk+fHiGDx8+T7uiKJIk3/zmN3PxxRdXOywAAAC0ClVdap4k7dq1y1VXXZUbbrghW2+9dZIPQ/ZHv5Jk2223zU033ZQrr7wylUql2mEBAACgVaj6jPccX/jCF/KFL3whb731VsaOHZs33ngjyYfP7R40aFBWXnnl5hoKAAAAWo1mC95z9OjRIzvttFNzHxYAAABapaovNQcAAAAWTPAGAACAEjXLpeazZs3Krbfemvvvvz8vvfRS3n333cyePXuhfSqVSu68887mGB4AAACWWVUH7wceeCAHHHBAXn311cZtc1Yyn59KpZKiKKxsDgAAwHKhquD9zDPPZLfddsv777+foijSsWPHfPKTn0yPHj3Srp2r2AEAAKCq4H322Wdn+vTpad++fc4888wcc8wxWWmllZqrNgAAAGj1qgred911VyqVSr7zne/k1FNPba6aAAAAoM2o6nrwN954I0nypS99qVmKAQAAgLamquC96qqrJklWWGGFZikGAAAA2pqqgvdnP/vZJMm4ceOapRgAAABoa6oK3scdd1zat2+fSy65JLNmzWqumgAAAKDNqCp4b7755rn44ovz+OOP58tf/nLjPd8AAADAh6pa1fxHP/pRkmSLLbbILbfckv79+2eXXXbJuuuumy5duiyy/2mnnVbN8AAAALDMqyp4n3HGGalUKkmSSqWS999/PzfffHNuvvnmJvUXvAEAAGjrqgreSVIUxUJfAwAAwPKsquDd0NDQXHUAAABAm1TV4moAAADAwgneAAAAUCLBGwAAAEokeAMAAECJmrS42lprrZXkw0eGvfjii/NsXxIfPxYAAAC0RU0K3uPHj0+Sxmd2f3z7kvj4sQAAAKAtalLwPvDAAxdrOwAAAPChJgXv4cOHL9Z2AAAA4EMWVwMAAIASNemM94IccsghSZLPfe5z2WeffZqlIAAAAGhLqgrev/vd75Ik++67b7MUAwAAAG1NVZear7rqqkmS3r17N0sxAAAA0NZUFbzXW2+9JMkrr7zSLMUAAABAW1NV8P7617+eoigaLzkHAAAA5lZV8D744IOz884758Ybb8wZZ5yRoiiaqy4AAABoE6paXO3+++/PCSeckNdffz1nnXVWrrvuuuy7777ZaKONsvLKK6d9+/YL7b/99ttXMzwAAAAs86oK3oMHD06lUml8/dxzz+Wss85qUt9KpZJZs2ZVMzwAAAAs86oK3klcXg4AAAALUVXwvvvuu5urDgAAAGiTqgreO+ywQ3PVAQAAAG1SVauaAwAAAAu3RGe8b7311vzzn//MK6+8ktmzZ2f11VfP4MGD89WvfjU1NTXNXSMAAAC0WosVvCdPnpy99toro0aNmmffb3/725x22mm54YYbsuGGGzZbgQAAANCaNflS89mzZ+cLX/hCHnnkkRRFMd+vl19+ObvuumveeOONMmsGAACAVqPJwfvPf/5zHn300VQqlay99tq56qqr8uSTT+aZZ57J9ddfn6222irJh2fFf/rTn5ZWMAAAALQmixW8k2TAgAEZNWpUDj744Ky//vr51Kc+lb333jv3339/dthhhxRFkeuvv760ggEAAKA1aXLw/ve//51KpZLjjz8+3bt3n2d/+/btc+aZZyZJXn755bz77rvNVmRzmDFjRjbeeONUKpWMHTt2rn1PPPFEtttuu3Tu3Dl9+/bN+eef3zJFAgAA0OY0OXi//vrrSZLNNttsgW0+um9Zu8/7e9/7XlZfffV5ttfV1WXo0KHp379/xowZkwsuuCBnnHFGrrzyyhaoEgAAgLamyauav//++6lUKllppZUW2KZLly6N33/wwQfVVdaMbrvttowYMSJ//etfc9ttt8217w9/+ENmzpyZ3/72t+nYsWPWX3/9jB07NhdddFEOP/zwFqoYAACAtqLJZ7wXV1EUZR16sUyePDmHHXZYrrnmmrn+MDDHQw89lO233z4dO3Zs3Lbrrrvm2Wefzdtvv700SwUAAKANWqzneLc2RVHkoIMOyre+9a1sttlmGT9+/DxtJk2alIEDB861rXfv3o37Vl555fkee8aMGZkxY0bj67q6uiRJfX196uvrm+kd0FrM+cx99jQn84oymFeUwbyiDOYVZWjOebU4x1js4H355ZenV69ezdLutNNOW9zhkyQnn3xyzjvvvIW2+c9//pMRI0bk3XffzSmnnLJE4yzMOeec07iY3EeNGDFivmfWWT6MHDmypUugDTKvKIN5RRnMK8pgXlGG5phX06dPb3LbStHEa8LbtWuXSqWyxEXNz+zZs5eo3+uvv54333xzoW3WWmutfPWrX83NN988V92zZ89O+/bts//+++d3v/tdvvGNb6Suri433HBDY5u77747O+20U956663FOuPdt2/fvPHGG+nWrdsSvS9ar/r6+owcOTK77LJLampqWroc2gjzijKYV5TBvKIM5hVlaM55VVdXl549e2bq1KmLzICLdca7Oe/bribEr7rqqll11VUX2e7nP/95fvzjHze+njhxYnbddddcd9112XLLLZMkW2+9db7//e+nvr6+8Qc/cuTIrLPOOgsM3UnSqVOndOrUaZ7tNTU1fjEsx3z+lMG8ogzmFWUwryiDeUUZmmNeLU7/Jgfvu+++e4mKaUn9+vWb6/WcFdk/8YlPZM0110yS7LfffjnzzDNz6KGH5qSTTsq4ceNyySWX5Gc/+9lSrxcAAIC2p8nBe4cddiizjhZTW1ubESNGZNiwYdl0003Ts2fPnHbaaR4lBgAAQLNo06uaf9yAAQPme7n8RhttlPvvv78FKgIAAKCtK+053gAAAIDgDQAAAKUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJlovgfeutt2bLLbfMCiuskJVXXjl77bXXXPtfffXV7LHHHunSpUt69eqVE088MbNmzWqZYgEAAGhTOrR0AWX761//msMOOyxnn312dtppp8yaNSvjxo1r3D979uzsscce6dOnTx588MG89tpr+cY3vpGampqcffbZLVg5AAAAbUGbDt6zZs3Kd77znVxwwQU59NBDG7evt956jd+PGDEiTz/9dO6444707t07G2+8cc4666ycdNJJOeOMM9KxY8eWKB0AAIA2ok0H78ceeywTJkxIu3btMmjQoEyaNCkbb7xxLrjggmywwQZJkoceeigbbrhhevfu3dhv1113zZFHHpmnnnoqgwYNmu+xZ8yYkRkzZjS+rqurS5LU19envr6+xHfFsmjOZ+6zpzmZV5TBvKIM5hVlMK8oQ3POq8U5RpsO3i+99FKS5IwzzshFF12UAQMG5Kc//WkGDx6c5557Lj169MikSZPmCt1JGl9PmjRpgcc+55xzcuaZZ86zfcSIEenSpUszvgtak5EjR7Z0CbRB5hVlMK8og3lFGcwrytAc82r69OlNbtsqg/fJJ5+c8847b6Ft/vOf/6ShoSFJ8v3vfz977713kmT48OFZc801c/311+eII45Y4hpOOeWUHHfccY2v6+rq0rdv3wwdOjTdunVb4uPSOtXX12fkyJHZZZddUlNT09Ll0EaYV5TBvKIM5hVlMK8oQ3POqzlXPTdFqwzexx9/fA466KCFtllrrbXy2muvJZn7nu5OnTplrbXWyquvvpok6dOnT0aNGjVX38mTJzfuW5BOnTqlU6dO82yvqanxi2E55vOnDOYVZTCvKIN5RRnMK8rQHPNqcfq3yuC96qqrZtVVV11ku0033TSdOnXKs88+m89+9rNJPvwLx/jx49O/f/8kydZbb52f/OQnmTJlSnr16pXkw8sOunXrNldgBwAAgCXRKoN3U3Xr1i3f+ta3cvrpp6dv377p379/LrjggiTJPvvskyQZOnRo1ltvvRxwwAE5//zzM2nSpPzgBz/IsGHD5ntGGwAAABZHmw7eSXLBBRekQ4cOOeCAA/L+++9nyy23zF133ZWVV145SdK+ffvccsstOfLII7P11ltnxRVXzIEHHpgf/ehHLVw5AAAAbUGbD941NTW58MILc+GFFy6wTf/+/fOPf/xjKVYFAADA8qJdSxcAAAAAbZngDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFCiDi1dAMunqdPrM27i1Ex9vz61K9Rkg9VrU9ulpqXLAgAAaHaCN0vdUxOn5o6np6ShKBq3jR7/doas1yvrr17bgpUBAAA0P5eas1RNnV4/T+hOkoaiyB1PT8nU6fUtVBkAAEA5BG+WqnETp84TuudoKIqMmzh1KVcEAABQLsGbpWrq+ws/o123iP0AAACtjeDNUlW7wsIXUOu2iP0AAACtjeDNUrXB6rVpV6nMd1+7SiUbWFwNAABoYwRvlqraLjUZsl6vecJ3u0olu6zX2yPFAACANsfjxFjq1l+9Nmt275JxE6em7v36dPMcbwAAoA0TvGkRtV1qsu3aPVu6DAAAgNK51BwAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiTq0dAFtRVEUSZK6uroWroSWUF9fn+nTp6euri41NTUtXQ5thHlFGcwrymBeUQbzijI057yak/3mZMGFEbybybvvvpsk6du3bwtXAgAAwNLy7rvvpra2dqFtKkVT4jmL1NDQkIkTJ6Zr166pVCotXQ5LWV1dXfr27Zv//ve/6datW0uXQxthXlEG84oymFeUwbyiDM05r4qiyLvvvpvVV1897dot/C5uZ7ybSbt27bLmmmu2dBm0sG7duvk/BpqdeUUZzCvKYF5RBvOKMjTXvFrUme45LK4GAAAAJRK8AQAAoESCNzSDTp065fTTT0+nTp1auhTaEPOKMphXlMG8ogzmFWVoqXllcTUAAAAokTPeAAAAUCLBGwAAAEokeAMAAECJBG+owvjx43PooYdm4MCBWWGFFfKJT3wip59+embOnDlXuyeeeCLbbbddOnfunL59++b8889voYppTS677LIMGDAgnTt3zpZbbplRo0a1dEm0Euecc04233zzdO3aNb169cpee+2VZ599dq42H3zwQYYNG5ZVVlklK620Uvbee+9Mnjy5hSqmNTr33HNTqVTy3e9+t3GbecWSmDBhQr7+9a9nlVVWyQorrJANN9wwo0ePbtxfFEVOO+20rLbaallhhRUyZMiQPP/88y1YMcu62bNn54c//OFc/41+1lln5aPLmy3teSV4QxWeeeaZNDQ05IorrshTTz2Vn/3sZ/nVr36VU089tbFNXV1dhg4dmv79+2fMmDG54IILcsYZZ+TKK69swcpZ1l133XU57rjjcvrpp+exxx7LZz7zmey6666ZMmVKS5dGK3Dvvfdm2LBhefjhhzNy5MjU19dn6NChmTZtWmObY489NjfffHOuv/763HvvvZk4cWK+/OUvt2DVtCaPPvporrjiimy00UZzbTevWFxvv/12tt1229TU1OS2227L008/nZ/+9KdZeeWVG9ucf/75+fnPf55f/epXeeSRR7Liiitm1113zQcffNCClbMsO++88/LLX/4yv/jFL/Kf//wn5513Xs4///xceumljW2W+rwqgGZ1/vnnFwMHDmx8ffnllxcrr7xyMWPGjMZtJ510UrHOOuu0RHm0EltssUUxbNiwxtezZ88uVl999eKcc85pwaporaZMmVIkKe69996iKIrinXfeKWpqaorrr7++sc1//vOfIknx0EMPtVSZtBLvvvtu8clPfrIYOXJkscMOOxTf+c53iqIwr1gyJ510UvHZz352gfsbGhqKPn36FBdccEHjtnfeeafo1KlT8ac//WlplEgrtMceexSHHHLIXNu+/OUvF/vvv39RFC0zr5zxhmY2derU9OjRo/H1Qw89lO233z4dO3Zs3Lbrrrvm2Wefzdtvv90SJbKMmzlzZsaMGZMhQ4Y0bmvXrl2GDBmShx56qAUro7WaOnVqkjT+bhozZkzq6+vnmmPrrrtu+vXrZ46xSMOGDcsee+wx1/xJzCuWzE033ZTNNtss++yzT3r16pVBgwbl17/+deP+l19+OZMmTZprXtXW1mbLLbc0r1igbbbZJnfeeWeee+65JMnjjz+eBx54IJ/73OeStMy86lDKUWE59cILL+TSSy/NhRde2Lht0qRJGThw4Fztevfu3bjvo5dSQZK88cYbmT17duM8maN379555plnWqgqWquGhoZ897vfzbbbbpsNNtggyYe/ezp27Jju3bvP1bZ3796ZNGlSC1RJa3Httdfmsccey6OPPjrPPvOKJfHSSy/ll7/8ZY477riceuqpefTRR3PMMcekY8eOOfDAAxvnzvz+P9G8YkFOPvnk1NXVZd1110379u0ze/bs/OQnP8n++++fJC0yr5zxhvk4+eSTU6lUFvr18QA0YcKE7Lbbbtlnn31y2GGHtVDlAHMbNmxYxo0bl2uvvbalS6GV++9//5vvfOc7+cMf/pDOnTu3dDm0EQ0NDdlkk01y9tlnZ9CgQTn88MNz2GGH5Ve/+lVLl0Yr9uc//zl/+MMf8sc//jGPPfZYfve73+XCCy/M7373uxaryRlvmI/jjz8+Bx100ELbrLXWWo3fT5w4MTvuuGO22WabeRZN69Onzzwrus553adPn+YpmDalZ8+ead++/XznjTnD4jjqqKNyyy235L777suaa67ZuL1Pnz6ZOXNm3nnnnbnOTppjLMyYMWMyZcqUbLLJJo3bZs+enfvuuy+/+MUvcvvtt5tXLLbVVlst66233lzbPv3pT+evf/1rkv//v5UmT56c1VZbrbHN5MmTs/HGGy+1OmldTjzxxJx88sn5f//v/yVJNtxww7zyyis555xzcuCBB7bIvHLGG+Zj1VVXzbrrrrvQrzn3bE+YMCGDBw/OpptumuHDh6ddu7n/WW299da57777Ul9f37ht5MiRWWeddVxmznx17Ngxm266ae68887GbQ0NDbnzzjuz9dZbt2BltBZFUeSoo47K3//+99x1113z3O6y6aabpqamZq459uyzz+bVV181x1ignXfeOU8++WTGjh3b+LXZZptl//33b/zevGJxbbvttvM87vC5555L//79kyQDBw5Mnz595ppXdXV1eeSRR8wrFmj69Onz/Dd5+/bt09DQkKSF5lUpS7bBcuJ///tfsfbaaxc777xz8b///a947bXXGr/meOedd4revXsXBxxwQDFu3Lji2muvLbp06VJcccUVLVg5y7prr7226NSpU3H11VcXTz/9dHH44YcX3bt3LyZNmtTSpdEKHHnkkUVtbW1xzz33zPV7afr06Y1tvvWtbxX9+vUr7rrrrmL06NHF1ltvXWy99dYtWDWt0UdXNS8K84rFN2rUqKJDhw7FT37yk+L5558v/vCHPxRdunQp/u///q+xzbnnnlt07969uPHGG4snnnii+OIXv1gMHDiweP/991uwcpZlBx54YLHGGmsUt9xyS/Hyyy8Xf/vb34qePXsW3/ve9xrbLO15JXhDFYYPH14kme/XRz3++OPFZz/72aJTp07FGmusUZx77rktVDGtyaWXXlr069ev6NixY7HFFlsUDz/8cEuXRCuxoN9Lw4cPb2zz/vvvF9/+9reLlVdeuejSpUvxpS99aa4/GkJTfDx4m1csiZtvvrnYYIMNik6dOhXrrrtuceWVV861v6GhofjhD39Y9O7du+jUqVOx8847F88++2wLVUtrUFdXV3znO98p+vXrV3Tu3LlYa621iu9///tzPd53ac+rSlEURTnn0gEAAAD3eAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4A9AsDjrooFQqlQwYMKClS1mgSqWSSqWSM844o6VLWajW8LMEAJpO8AZoo+65557GoPnxry5duqR///7Za6+98sc//jGzZs1q6XIp2ejRo3PKKadkq622yhprrJFOnTqlW7du+cQnPpGvfOUrueKKK/LOO++0dJksZ957773cd999ufDCC/PVr341AwcObPw95Q9PQFvSoaULAGDpe//99/Pqq6/m1VdfzY033piLL744N910U/r06dPSpdHMXnnllRx11FG55ZZb5tk3c+bMvPvuu3nppZfy17/+Nccee2yOPfbY/OAHP8gKK6zQAtWyrBswYEBeeeWVHHjggbn66qurPt6ee+6Ze+65p+rjACzrBG+A5cCRRx6Zb3/7242v33vvvYwePTo//elPM378+Dz66KP54he/mIcffjiVSmWJxrj66qub5T/Ey1QURUuXsFSNHj06n//85zN58uQkH4amr33ta9lmm23Su3fvzJw5M//73/9yxx135O9//3vefPPNnH322dlnn32y8cYbt2zxLBc++m+yR48e2WyzzfLggw/mvffea8GqAJqf4A2wHOjVq1c22GCDubZttdVW2X///bPFFlvkhRdeyKhRo3LLLbdkzz33bKEqaU6TJk2aK3T/4Ac/yA9/+MN07Nhxnrb77rtvLrroovz0pz/N2WefvbRLZTm233775Ygjjsjmm2+etddeO8mHfyASvIG2xj3eAMuxlVdeOaecckrj63/+858tWA3N6YgjjmgM3WeddVbOOuus+YbuObp27Zozzjgjd955Z2pra5dWmSznDj/88Hzta19rDN0AbZXgDbCc22KLLRq/f+WVVxq//+jibPfcc08aGhry29/+NjvuuGN69+6ddu3a5aCDDmpsv6iVuD++ovijjz6ar33ta1lzzTXTqVOnrLHGGjnggAPyn//8p0l1jxs3LkcffXQ23HDDrLzyyqmpqUmfPn0yZMiQnH/++XnttdcWWcNHXX311Y37x48fnxkzZuTCCy/MJptsktra2nTr1i1bbrllLr/88syePXuBdTU0NOSuu+7KCSeckG233TY9e/ZMTU1Nunfvno033jgnnHBCXn311Sa9xyU1bty43HTTTUmSjTfeeK4/rizKdtttl4EDB8533+uvv54f/OAHGTRoULp3757OnTtnwIABOeCAA/LAAw8s9LgDBgxIpVJpnDOPPfZY9t9///Tt2zcrrLBC1l577Rx33HF544035ur34IMPZp999km/fv3SuXPnfOITn8hJJ52Ud999d4FjDR48OJVKJYMHD06SPPvsszn88MMzcODAdO7cOauttlq++tWv5uGHH27Sz+SBBx7IAQcckAEDBqRz587p3r17Bg0alB/84Ad5/fXXF9jv4/+GkuTPf/5zdt5556y66qpZYYUVss466+R73/te3nrrrSbVcsMNN8z18+jevXs222yznHnmmXn77bcX2O/j/z7feeednHbaaVl//fWz4oorpnv37tl+++3zhz/8Yb795/xM5/yO+N3vfjfPoo1zft4AzEcBQJt09913F0mKJMXpp5++wHbPPPNMY7vddtttvv1vu+22YsiQIY2v53wdeOCBje0PPPDAIknRv3//+Y7z0Vouu+yyokOHDvMcL0nRpUuX4t57711gvbNmzSqOPfbYolKpzLf//GqbXw0fN3z48Mb9jz32WLHpppsu8Njbb7998e677863vtNPP32hdc15j3/7298W+B4X9bNclOOOO65xrKuuumqJjvFxt99+e9GtW7eFvq9hw4YVs2fPnm///v37N34uv//974uOHTvO9xif+tSnitdee60oiqK44IILFvg5b7LJJgv8DHbYYYciSbHDDjsU//jHP4oVV1xxvsdo165d8bOf/WyB73n27NnFsGHDFvqea2trixEjRsy3/0f/Dd15553F17/+9QUeZ+2112583/Pz1ltvFTvttNNCa+nVq1fx0EMPzbf/R+fUM888UwwYMGChn+OCfqYL+9phhx0WWP/imDNXlnT+AyyLnPEGWM49+eSTjd+vvvrq821z0kkn5Y477sgXvvCF/O1vf8uYMWPyj3/8I5/73OcWe7zbb789Rx99dNZff/389re/zaOPPpr77rsvxx57bNq1a5fp06fngAMOyMyZM+fb//DDD8/PfvazFEWR1VZbLT/5yU9y991357HHHsvtt9+es846K5/5zGcWu66POuKIIzJmzJjsu++++cc//pHRo0fnj3/8YzbffPMkyX333ZcDDjhgvn1nzZqV1VZbLd/+9rdzzTXX5F//+lfGjBmTG264Id/73vey0korZfr06dlvv/2afHZ/cd17772N3++xxx5VH2/s2LHZc889U1dXl5qamhx77LG5++67M2rUqFxxxRWNZ8gvu+yyRZ5df/zxx/PNb34za6+9duPnf9ddd+XrX/96kuS5557LCSeckL/97W858cQTs+WWW+YPf/hDRo8enX/+85/Zfffdk3x4xvzHP/7xQseaOHFi9ttvv3To0CFnn312HnzwwTz44IP5yU9+km7duqWhoSHHHntsbrjhhvn2P/nkk3PZZZclSQYOHJhf/epXGTVqVO6+++4ce+yxqampydSpU/P5z38+jz/++EJr+eEPf5j/+7//y1577TXXv6E5n88LL7yQY489dr59Z8yYkSFDhuSuu+5K+/btc8ABB+RPf/pTHn744dx///35yU9+klVWWSVTpkzJ7rvvPteVKx83ffr07LnnnnnzzTfzgx/8IPfcc09Gjx6dX//611lzzTWTfPg53n777XP1Gz58eJ588snG3xFf/OIX8+STT871NXz48IX+DACWay2d/AEoR1POeNfX1xdbbbVVY7vf//738+2fpPjBD36w0PGaesY7SbH77rsXM2bMmKfNj3/848Y28zsjfOONNzbu33rrrYu33357gfW8+uqrC6xhUWe8kxRnn332PG3q6+uLXXfdtbHNrbfeOk+bl19+uZg5c+YC6/rvf/9brLHGGkWS4utf//p821R7xrumpqZIUqyxxhpL1P/jNt988yJJ0b59++L222+fZ/9bb71VrLfeeo1nkceNGzdPmzlnMZMU22yzTTFt2rR52nzlK19pHKdHjx7F3nvvXcyaNWuuNrNmzWqcs6usskpRX18/z3E+ena2tra2ePrpp+dpM27cuMYz+GusscY8n9kTTzxRtGvXrkhSbLDBBvOda7fddltjmy222GKe/R//N/TjH/94njYNDQ3F0KFDiyRFhw4diilTpszT5tRTTy2SFN27dy9Gjx49z/6iKIrx48cXq622WpGk2G+//ebZP2dOzfmZzO8zev7554vOnTsXSYovfOEL8x3no1culMUZb6AtcsYbYDk0bdq03Hvvvdlll10a73Pt379/vvrVr863/ac+9an53he9JDp37pzhw4fPd6GvY445pnH7/fffP8/+c889N0nSpUuX/OUvf0n37t0XOE7fvn2XuMaNNtooJ5988jzbO3TokN/85jepqalJklx++eXztBkwYEDj/vlZc801c+KJJyZJbrrppmZ/xFldXV3q6+uTfLiafbVGjRqVRx99NEly2GGHZejQofO0WXnllXPllVcm+fAe9/n9XOaoVCr5zW9+ky5dusyzb84j72bPnp0PPvggV155Zdq3bz9Xm/bt2+fwww9Pkrz55pt5+umnF1r/D3/4w3z605+eZ/v666+f73//+0mSCRMm5MYbb5xr/y9/+cs0NDQkSX7zm9/Md67ttttuOeSQQ5LM/XOan0033TSnnnrqPNsrlUqOO+64JB9eLfHQQw/Ntf+9995rPOt+1llnZdNNN53v8fv3758f/vCHSZLrr78+06ZNW2AtZ511VtZff/15tq+99trZa6+9kmSR9+wDsHgEb4DlwJlnnjnXIkgrrbRSBg8e3LjgU69evXLDDTekU6dO8+2/7777zhOAltQuu+yywEDYtWvXfPKTn0ySvPTSS3Pte/PNNxv/SLDvvvsu8LL45nDggQcu8Hnma665ZmP4vOeeexa60FryYRB++eWX89RTT2XcuHEZN25cY+ics685fXTRsRVXXLHq491xxx2N3x966KELbLfttts2BtyP9vm4jTbaaL5BOMlctwjssssu6dGjxyLbfXyefFSlUsmBBx64wP0HH3xw4+f88ZrnvF5//fWz5ZZbLvAYhx122Dx95me//fZb4Jz6aJj++Pu59957M3Xq1CTJV77ylQUeP0m23377JEl9fX3GjBkz3zaVSiX77bffAo8xp5a33nor77zzzkLHA6DpBG+A5djAgQNz4okn5sknn8zGG2+8wHYbbbRRs4257rrrLnT/nLD18VWrx44d23h2eLvttmu2euZnzr3cCzJnJfhp06bNN/i98sorOfroozNgwIDU1tZmrbXWygYbbJANN9wwG264YeMZ2yTzrOJdra5duzZ+v7Cznk01bty4JEnHjh0XOkeSNAbU559/foH36H/qU59aYP+PnlVuaruFrW4+cODA9OzZc4H7V1111cZVvj+61sGMGTPy/PPPJ8lCQ3eSDBo0qPEKhzk/q/lZ2Lz/6B8YPv5+Ro8e3fj9aqutNs9K4h/92mCDDRrbTpo0ab5j9ezZM6usssoS1QLAkuvQ0gUAUL4jjzyy8TLeSqWSzp07p2fPnk1+XvPKK6/cbLXM7xLjj2rX7sO/CX/8TPJHA+pqq63WbPXMz6Iu0e7du3fj9x9/DNRtt92Wr3zlK5k+fXqTxnr//fcXv8CF6NatW2pqalJfX9/4HO9qzHl/PXr0SIcOC//Phj59+iRJiqLI22+/PdfPaY6Fff5zPvvFabewKw6acql979698/LLL8/1OX70sVyLOkZNTU1WWWWVTJo0aaGPBFvS9zNlypSFjr8gC5p/Tf33N79aAFhygjfAcqBXr15znQ1bXM11mXlrsaBLghfljTfeyH777Zfp06dnpZVWygknnJBdd901n/jEJ1JbW9t4//pdd92VnXfeOUma/R7v5MMrFMaMGZOJEydm8uTJ8w3Ai2tJfyYtqTlqbun3/dHw+9hjjy10/YCPmrNCOQDLBsEbgFbho5cMv/baa6WONXny5IVe6vzRM8kfvTT3L3/5S+N9sX//+98zZMiQ+fZf2JnR5rDDDjs03uN76623Ni4AtiTmvL8333wzs2bNWuhZ7zmXN1cqlWa9SmJJNeWM/5w2H/0cP1r7oo4xa9asvPnmm/Mco7l89LLwVVddVaAGaKXc4w1AqzBo0KDGs4/33XdfqWMtbHXqj+7v0qVL1lprrcbtTz31VJIPA9iCQncy9327ZTjooIMav7/00ksbV+deEnOulJg5c2bGjh270LajRo1Kknzyk5+c76r1S9vLL7/cGIrn5/XXX8/48eOTZK4rQjp16tS4yN8jjzyy0DH+/e9/N64iX81VJQsyaNCgxu//9a9/NfvxF1dLXwEA0FoJ3gC0Cj169Mg222yTJPnzn/+ciRMnljbWNddcs8BLwCdMmJARI0YkSQYPHjzXZfizZs1KknzwwQcLDLvTp0/PNddc08wVz23DDTfMF77whSQfLkp39tlnN7nvAw88MNdK6x/9A8Jvf/vbBfZ76KGHGh/ttbA/OixNRVHk97///QL3X3311Y2f88drnvP6qaeeavyDwvz85je/madPcxoyZEjjfdk///nPS7k1YXF07tw5yYcL0AHQdII3AK3GSSedlOTD8LrPPvs0PmZpfv73v/8t8Thjx47NBRdcMM/2WbNm5bDDDmtcsfvII4+ca/+cs6TTp0/Pn//853n6z549O9/85jdL/aPBHFdccUXjvd0//OEPc9pppy1wpfHkwxXQzzzzzOy0005z/Vy32GKLbLbZZkmSX//617nzzjvn6Tt16tQcccQRST5cnOvjP5eWdNZZZ+XZZ5+dZ/t//vOf/OQnP0ny4WJ9X/ziF+faf+SRRzYuNHb44Yenrq5unmOMGDEiV111VZIPf06LWg1/SXTv3j1HHXVUkuTBBx/Mscceu9ArGCZPnjzXHwOa25yFDV988cXSxgBoi9zjDUCrseeee+bQQw/NVVddlQcffDDrrbdejjrqqGy77bbp1q1b3njjjYwePTrXXXddPvOZz+Tqq69eonE222yznHTSSRk7dmy+8Y1vpFevXnn++edz0UUXNZ793HPPPfP5z39+rn5f/epXc+qpp2bGjBk5+OCDM3bs2Oyyyy6pra3NU089lUsvvTRjxozJtttuW/plw3369Mktt9ySz3/+85k8eXLOOuusXHPNNdlvv/2y7bbbplevXpk5c2YmTJiQu+66K3/961/z+uuvz/dYv/71r7Pllltm5syZ2X333XP00Udnzz33zIorrph///vfOffccxsfq3bCCSeUcsn1klh77bXz+uuvZ6uttspJJ52UwYMHJ/nw+evnnntu4x8YLr300nkujd9www1z/PHH54ILLsjjjz+eTTbZJCeddFIGDRqUadOm5eabb87Pf/7zzJ49Ox07dswVV1xR2vv40Y9+lHvvvTePPPJILrnkktxzzz057LDDsvHGG2fFFVfM22+/naeeeip33HFHbrvttmy44Yb55je/WUot22yzTe6+++48+uijOffcc/O5z32u8XnxK6ywQtZYY43FOt4LL7yQBx54YK5t7733XuP/fvzf8G677da4ej5Aq1IA0CbdfffdRZIiSXH66adX1f/uu+9eZPsDDzywSFL0799/vvubWssOO+xQJCl22GGH+e6fNWtWcdRRRxWVSqXxmPP7OvDAAxerhuHDhzfuf+yxx4pBgwYt8NjbbrttUVdXN9/6fvvb3xbt2rVbYN999923uOOOOxb6s13Uz3JxjB8/vthjjz0W+rOa87XiiisWZ5xxRvHBBx/Mc5zbb7+96Nat20L7Dxs2rJg9e/Z86+jfv/8CP5ePaso8efnllxvbDR8+fJ79H51Dt9xyS9GlS5f51tuuXbviwgsvXOA4s2fPLr797W8v9D3X1tYWt99++3z7L86/oUW977q6uuLLX/5ykz7HHXfccZ7+TZ1TH/138PLLL8+z/3//+1/Ro0eP+Y67oH+zTR2vKV9N+V0EsCxyqTkArUr79u1z6aWXZvTo0Tn88MPzqU99KiuuuGJqamrSp0+fDB06NBdddFEuvPDCJR5j5ZVXzoMPPphzzjknG2+8cbp27ZqVVlopm2++eS699NLce++96dq163z7Hnzwwbn//vuz1157ZdVVV01NTU1WW2217Lbbbrnuuuty7bXXLtXHs/Xv3z+33HJLRo0alZNOOilbbLFFVltttXTs2DErrbRS1lprrXzlK1/JlVdemYkTJ+b0009Pp06d5jnO0KFD88ILL+TUU0/NxhtvnG7duqVTp07p169f9t9//9x///35xS9+MddzoJcFe+yxR0aPHp2DDz44/fv3T8eOHdOrV6/svffeeeCBB3L88ccvsG+7du1y2WWX5b777sv++++ffv36pVOnTunWrVs23njjnHrqqXn++eczdOjQ0t9H165d89e//jX3339/vvnNb2adddZJ165d06FDh/To0SObb755hg0bln/84x8ZOXJkaXWsscYaGTVqVA499NCsvfbajfd8A7BwlaJo4VU6AGAZcPXVV+fggw9O8uFq2AMGDGjZglhigwcPzr333psddtgh99xzT0uXAwAWVwMAAIAyCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiRVc0BAACgRM54AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAif4/qykIekosEewAAAAASUVORK5CYII=", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAMWCAYAAADs4eXxAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAABloElEQVR4nO39eZxWdf0//j8uYABRGEQEXNjMytwS9yUVFdE0yzLzm2ZuqRlqueVSuWS5Z5ppaRlW70qzxTUT3DUXBENFcxctEHBlFBQG5vz+8Md8RLaBaw6zeL/fbnNrrnNer/N6XnO9mHzMOed1KkVRFAEAAACaXYeWLgAAAADaK6EbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJJ0aukC2ouGhoZMnjw53bt3T6VSaelyAAAAKFFRFHn77bez+uqrp0OHRZ/PFrqbyeTJk9O/f/+WLgMAAIDl6L///W/WXHPNRe4XuptJ9+7dk7z/A+/Ro0cLV8PyVl9fn1GjRmX48OGpqalp6XJoJ8wrymBeUQbzijKYV5ShOedVXV1d+vfv35gFF0XobibzLinv0aOH0P0RVF9fn27duqVHjx7+T4FmY15RBvOKMphXlMG8ogxlzKsl3V5sITUAAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoSaeWLgA+SqbPrM+EydMz/d361K5Qk/VXr01tt5qWLgsAACiJ0A3LyROTp+e2J6eloSgat42d+GaGrdsn661e24KVAQAAZXF5OSwH02fWLxC4k6ShKHLbk9MyfWZ9C1UGAACUSeiG5WDC5OkLBO55GooiEyZPX84VAQAAy4PQDcvB9HcXfya7bgn7AQCAtknohuWgdoXFL5bWYwn7AQCAtknohuVg/dVr06FSWei+DpVK1reQGgAAtEtCNywHtd1qMmzdPgsE7w6VSnZet6/HhgEAQDvlkWGwnKy3em3W7NktEyZPT9279enhOd0AANDuCd2wHNV2q8k2a/du6TIAAIDlxOXlAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQko9c6D7nnHNSqVTyne98p3Hbe++9lxEjRmSVVVbJSiutlL322itTp05tuSIBAABoFz5Sofvhhx/O5Zdfng033HC+7cccc0xuvPHGXHvttbn77rszefLkfOlLX2qhKgEAAGgvPjKh+5133sl+++2XX/3qV1l55ZUbt0+fPj1XXnllLrzwwuy4447ZZJNNMnLkyNx///158MEHW7BiAAAA2rqPTOgeMWJEdt999wwbNmy+7ePGjUt9ff1829dZZ50MGDAgDzzwwPIuEwAAgHakU0sXsDxcffXVeeSRR/Lwww8vsG/KlCnp3LlzevbsOd/2vn37ZsqUKYs85qxZszJr1qzG13V1dUmS+vr61NfXN0/htBnzPnOfPc3JvKIM5hVlMK8og3lFGZpzXjX1GO0+dP/3v//Nt7/97YwePTpdu3ZttuOeffbZOeOMMxbYPmrUqHTr1q3ZxqFtGT16dEuXQDtkXlEG84oymFeUwbyiDM0xr2bOnNmkdpWiKIqqR2vFrrvuunzxi19Mx44dG7fNnTs3lUolHTp0yK233pphw4blzTffnO9s98CBA/Od73wnxxxzzEKPu7Az3f37989rr72WHj16lPZ+aJ3q6+szevTo7LzzzqmpqWnpcmgnzCvKYF5RBvOKMphXlKE551VdXV169+6d6dOnLzYDtvsz3TvttFMef/zx+bYddNBBWWeddXLiiSemf//+qampye2335699torSfL000/n5ZdfzlZbbbXI43bp0iVdunRZYHtNTY1fCh9hPn/KYF5RBvOKMphXlMG8ogzNMa+a2r/dh+7u3btn/fXXn2/biiuumFVWWaVx+yGHHJJjjz02vXr1So8ePXLUUUdlq622ypZbbtkSJQMAANBOtPvQ3RQ//elP06FDh+y1116ZNWtWdtlll1x22WUtXRYAAABt3EcydN91113zve7atWsuvfTSXHrppS1TEAAAAO3SR+Y53QAAALC8Cd0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAl6dTSBVC+6TPrM2Hy9Ex/tz61K9Rk/dVrU9utpqXLAgAAaPeE7nbuicnTc9uT09JQFI3bxk58M8PW7ZP1Vq9twcoAAADaP5eXt2PTZ9YvELiTpKEoctuT0zJ9Zn0LVQYAAPDRIHS3YxMmT18gcM/TUBSZMHn6cq4IAADgo0Xobsemv7v4M9l1S9gPAABAdYTudqx2hcUvltZjCfsBAACojtDdjq2/em06VCoL3dehUsn6FlIDAAAoldDdjtV2q8mwdfssELw7VCrZed2+HhsGAABQMo8Ma+fWW702a/bslgmTp6fu3fr08JxuAACA5Ubo/gio7VaTbdbu3dJlAAAAfOS4vBwAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQknYfus8+++xsttlm6d69e/r06ZM999wzTz/99Hxt3nvvvYwYMSKrrLJKVlpppey1116ZOnVqC1UMAABAe9HuQ/fdd9+dESNG5MEHH8zo0aNTX1+f4cOHZ8aMGY1tjjnmmNx444259tprc/fdd2fy5Mn50pe+1IJVAwAA0B50aukCyvbPf/5zvtdXXXVV+vTpk3HjxmW77bbL9OnTc+WVV+aPf/xjdtxxxyTJyJEj86lPfSoPPvhgttxyy5YoGwAAgHag3YfuD5s+fXqSpFevXkmScePGpb6+PsOGDWtss84662TAgAF54IEHFhm6Z82alVmzZjW+rqurS5LU19envr6+rPJppeZ95j57mpN5RRnMK8pgXlEG84oyNOe8auoxPlKhu6GhId/5zneyzTbbZP3110+STJkyJZ07d07Pnj3na9u3b99MmTJlkcc6++yzc8YZZyywfdSoUenWrVuz1k3bMXr06JYugXbIvKIM5hVlMK8og3lFGZpjXs2cObNJ7T5SoXvEiBGZMGFC7rvvvqqPdfLJJ+fYY49tfF1XV5f+/ftn+PDh6dGjR9XHp22pr6/P6NGjs/POO6empqaly6GdMK8og3lFGcwrymBeUYbmnFfzrnZeko9M6D7yyCNz00035Z577smaa67ZuL1fv36ZPXt23nrrrfnOdk+dOjX9+vVb5PG6dOmSLl26LLC9pqbGL4WPMJ8/ZTCvKIN5RRnMK8pgXlGG5phXTe3f7lcvL4oiRx55ZP7+97/njjvuyODBg+fbv8kmm6Smpia3335747ann346L7/8crbaaqvlXS4AAADtSLs/0z1ixIj88Y9/zPXXX5/u3bs33qddW1ubFVZYIbW1tTnkkENy7LHHplevXunRo0eOOuqobLXVVlYuBwAAoCrtPnT/4he/SJIMHTp0vu0jR47MgQcemCT56U9/mg4dOmSvvfbKrFmzsssuu+Syyy5bzpUCAADQ3rT70F0UxRLbdO3aNZdeemkuvfTS5VARAAAAHxXt/p5uAAAAaClCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAAStJpeQ84d+7cTJo0KUkyYMCA5T08AAAALDdLfaZ75syZ+dGPfpSNNtoo3bt3T8+ePbPVVlvloosuynvvvbfE/k899VQGDRqUtdZaa5kKBgAAgLZiqc50//e//83OO++cZ599NklSFEWSZMyYMRkzZkwuuuii/Pa3v83222+/xGPN6wsAAADtVZPPdM+dOzdf/vKX88wzz6QoitTU1GTTTTfNBhtskI4dO6Yoirz88svZaaed8pOf/KTMmgEAAKBNaHLo/stf/pKHH344lUolX/nKVzJ58uSMGTMmjz76aCZNmpTjjjsunTp1SkNDQ7773e/mpJNOKrNuAAAAaPWaHLqvvvrqJMlmm22WP/3pT+nVq1fjvlVXXTXnn39+7r333qy55popiiLnn39+vvnNbzZ/xQAAANBGNDl0jx07NpVKJUcffXQqlcpC22yxxRZ5+OGHs/HGG6coivzqV7/Kfvvtl4aGhmYrGAAAANqKJofu1157LUnyqU99arHt+vbtm7vuuitDhw5NURS5+uqrs9dee6W+vr66SgEAAKCNaXLo7tDh/aZNeSzYSiutlFtuuSWf+9znUhRFbrjhhnzuc5/Lu+++u+yVAgAAQBvT5NC9xhprJEnj48KWpEuXLvn73/+effbZJ0VR5Lbbbssuu+ySurq6ZasUAAAA2pgmh+4NN9wwSXL77bc3+eAdO3bMH//4xxxyyCEpiiL/+te/ss8++yx9lQAAANAGNTl0z7tH++9//3tmzJjR5AEqlUp+9atf5Tvf+U6KosikSZOWqVAAAABoa5ocuvfYY49UKpXMmDEjv/jFL5Z6oAsvvDCnnnpqiqJY6r4AAADQFnVqasOBAwfme9/7Xl555ZW8/vrryzTY6aefnlVWWSV/+9vflqk/AAAAtCVNDt1J8sMf/rDqAY866qgcddRRVR8HAAAAWrsmX14OAAAALB2hGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASlJV6N5xxx2z00475aWXXmpyn8mTJzf2AwAAgPasUzWd77rrrlQqlcyYMaPJfd59993GfgAAANCeubwcAAAASrLcQ/e8s+Jdu3Zd3kMDAADAcrXcQ/ctt9ySJFlzzTWX99AAAACwXC3VPd0HH3zwQrd///vfT8+ePRfbd9asWXn++efz8MMPp1KpZPvtt1+aoQEAAKDNWarQfdVVVy2wAFpRFLn++uub1L8oiiRJr169cvLJJy/N0AAAANDmLFXoHjBgwHyh+6WXXkqlUslqq62WmpqaRfarVCrp2rVrVltttWy99dY54ogjsvrqqy971QAAANAGLFXonjhx4nyvO3R4/5bwUaNGZd111222ogAAAKA9qOo53dttt10qlUpWXHHF5qoHAAAA2o2qQvddd93VTGUAAABA+7PcHxkGAAAAHxVVnelemLq6urz99tuZO3fuEtsOGDCguYcHAACAVqNZQvfo0aNz2WWX5b777ssbb7zRpD6VSiVz5sxpjuEBAACgVao6dB999NG59NJLk/y/53ADAAAAVYbuP/7xj/n5z3+eJOnatWv23HPPbLLJJunVq1fj48QAAADgo6qq0H355ZcnSfr375877rgjH/vYx5qlKAAAAGgPqjod/dhjj6VSqeS0004TuAEAAOBDqgrd9fX1SZIhQ4Y0SzEAAADQnlQVugcNGpQkeeedd5qjFgAAAGhXqgrdX/rSl5Ikt99+e7MUAwAAAO1JVaH7uOOOy4ABA3LRRRflqaeeaq6aAAAAoF2oKnTX1tbm1ltvTd++fbP11lvnsssuy5tvvtlctQEAAECbVtUjw9Zaa60kycyZM/PWW2/lqKOOytFHH53evXunW7dui+1bqVTy/PPPVzM8AAAAtGpVhe6JEyfO97ooihRFkWnTpi2xb6VSqWZoAAAAaPWqCt0HHHBAc9UBAAAA7U5VoXvkyJHNVQcAAAC0O1UtpAYAAAAsmtANAAAAJanq8vIPe/fddzNu3LhMmTIlM2fOzJ577pkePXo05xAAAADQZjRL6P7vf/+bU045Jddee23q6+sbt2+66aZZd911G19feeWVufzyy1NbW5tRo0ZZwRwAAIB2rerLyx966KEMGTIkf/zjHzN79uzGx4YtzB577JHHHnssd9xxR0aNGlXt0AAAANCqVRW633rrrXzhC1/IG2+8kX79+uWyyy7L448/vsj2ffr0yWc/+9kkyc0331zN0AAAANDqVXV5+c9+9rNMmzYtvXv3zgMPPJABAwYssc+wYcNy/fXXZ8yYMdUMDQAAAK1eVWe6b7zxxlQqlRx77LFNCtxJst566yVJnn/++WqGBgAAgFavqtD93HPPJUm22267JvdZeeWVkyR1dXXVDA0AAACtXlWh+7333kuS1NTUNLnPjBkzkiQrrLBCNUMDAABAq1dV6O7Tp0+S5MUXX2xyn/HjxydJVl999WqGBgAAgFavqtC9xRZbJEluueWWJrUviiK/+tWvUqlUsu2221YzNAAAALR6VYXu/fbbL0VR5A9/+EPjGezFOe644/Loo48mSQ444IBqhgYAAIBWr6rQ/YUvfCE77LBD5syZk5122im/+MUvMm3atMb9c+bMyeTJk3Pttddm2223zcUXX5xKpZIvfelL2XrrrasuHgAAAFqzqp7TnSR//etfs9NOO+Xf//53jjzyyBx55JGpVCpJkiFDhszXtiiKbLnllrnqqquqHRYAAABavarOdCdJz54988ADD+Tkk09Ojx49UhTFQr9WWGGFfPe7381dd92VFVdcsTlqBwAAgFat6jPdSdK5c+f8+Mc/zimnnJK77747Y8eOzbRp0zJ37tysssoqGTJkSIYNG5ba2trmGA4AAADahGYJ3fOsuOKK2W233bLbbrs152EBAACgTar68nIAAABg4YRuAAAAKEmzXV7++uuv54EHHsgLL7yQt99+O3Pnzl1in1NPPbW5hgcAAIBWp+rQPW3atBxzzDH5y1/+kjlz5ixVX6EbAACA9qyqy8vffPPNfOYzn8nVV1+d+vr6RT4ubFFfrc2ll16aQYMGpWvXrtliiy0yZsyYli4JAACANqyq0H3OOefkueeeS1EUGT58eP75z3/m1Vdfzdy5c9PQ0LDEr9bkmmuuybHHHpvTTjstjzzySD796U9nl112ybRp01q6NAAAANqoqkL39ddfn0qlks997nP55z//meHDh2eVVVZJpVJprvqWmwsvvDCHHnpoDjrooKy77rr55S9/mW7duuU3v/lNS5cGAABAG1XVPd0vv/xykmTEiBHNUkxLmT17dsaNG5eTTz65cVuHDh0ybNiwPPDAAwvtM2vWrMyaNavxdV1dXZKkvr4+9fX15RZMqzPvM/fZ05zMK8pgXlEG84oymFeUoTnnVVOPUVXoXmmllTJr1qz07du3msO0uNdeey1z585d4H307ds3Tz311EL7nH322TnjjDMW2D5q1Kh069atlDpp/UaPHt3SJdAOmVeUwbyiDOYVZTCvKENzzKuZM2c2qV1VoXuDDTbIXXfdlZdeeikbbbRRNYdqc04++eQce+yxja/r6urSv3//DB8+PD169GjBymgJ9fX1GT16dHbeeefU1NS0dDm0E+YVZTCvKIN5RRnMK8rQnPNq3tXOS1JV6D788MNz55135ve//32+8IUvVHOoFtW7d+907NgxU6dOnW/71KlT069fv4X26dKlS7p06bLA9pqaGr8UPsJ8/pTBvKIM5hVlMK8og3lFGZpjXjW1f1ULqX3lK1/Jfvvtl7///e8555xzqjlUi+rcuXM22WST3H777Y3bGhoacvvtt2errbZqwcoAAABoy6o6033PPffkkEMOyYsvvpjvfe97+dvf/pZ9990366yzTpPua95uu+2qGb5ZHXvssTnggAOy6aabZvPNN89FF12UGTNm5KCDDmrp0gAAAGijqgrdQ4cOne/xYOPGjcu4ceOa1LdSqWTOnDnVDN+s9tlnn7z66qs59dRTM2XKlGy00Ub55z//2eYXiQMAAKDlVBW6k6Qoiuaoo1U48sgjc+SRR7Z0GQAAALQTVYXuO++8s7nqAAAAgHanqtC9/fbbN1cdAAAAkOkz6zNh8vRMf7c+tSvUZP3Va1Pbre2uYF/15eUAAADQHJ6YPD23PTktDR+4jXnsxDczbN0+WW/12hasbNlV9cgwAAAAaA7TZ9YvELiTpKEoctuT0zJ9Zn0LVVadZj3TPW7cuNx2222ZMGFC3njjjSRJr169sv7662fYsGHZZJNNmnM4AAAA2okJk6cvELjnaSiKTJg8Pdus3Xs5V1W9Zgndjz/+eA477LCMGTNmkW1OOeWUbLHFFrn88suzwQYbNMewAAAAtBPT3138mey6Jexvraq+vPy2227L5ptvnjFjxqQoihRFkU6dOqVv377p27dvOnXq1Lj9wQcfzOabb57bb7+9OWoHAACgnahdYfGLpfVYwv7WqqrQ/dprr2XvvffOrFmzUqlU8o1vfCMPPfRQZsyYkcmTJ2fy5MmZOXNmxowZk0MPPTQdO3bMrFmzsvfee+f1119vrvcAAABAG7f+6rXpUKksdF+HSiXrfxQXUrv44oszffr0dO7cOTfffHOuuOKKbLbZZunU6f9dtd6xY8dsuummufzyy3PzzTenpqYm06dPz8UXX1x18QAAALQPtd1qMmzdPgsE7w6VSnZet2+bfWxYVaH75ptvTqVSyZFHHplddtllie2HDx+eo446KkVR5Oabb65maAAAANqZ9VavzYFbD8rmg3tlnX7ds/ngXjlw60FZd/UeLV3aMqsqdL/44otJks9//vNN7jOv7QsvvFDN0AAAALRDtd1qss3avfPZDVbLNmv3brNnuOepKnS/9957SZIVV1yxyX3mtZ01a1Y1QwMAAECrV1Xo7tevX5Lk3//+d5P7zGvbt2/faoYGAACAVq+q0L3tttumKIqcc845qaurW2L7t99+O+eee24qlUq23XbbaoYGAACAVq+q0H344Ycnef/e7u222y5jx45dZNuxY8dm++23z/PPPz9fXwAAAGivOi25yaJts802+da3vpXLLrssjz/+eLbYYoust9562WKLLdKnT59UKpVMnTo1Dz30UJ544onGft/61reyzTbbVF08AAAAtGZVhe4kueSSS9KtW7dceOGFaWhoyIQJE+YL2ElSFEWSpEOHDjn++ONzzjnnVDssAAAAtHpVXV6eJJVKJeedd17Gjx+fI444Ih//+MdTFMV8Xx//+MdzxBFHZPz48Y33dAMAAEB7V/WZ7nnWX3/9XHrppUmS2bNn580330ySrLzyyuncuXNzDQMAAABtRrOF7g/q3LmzR4IBAADwkVf15eUAAADAwjXbme65c+fm+uuvz2233ZbHH388b7zxRpKkV69eWX/99TNs2LB84QtfSKdOpZxcBwAAgFanWRLwDTfckCOPPDKTJk1q3DZvxfJKpZL7778/V1xxRVZbbbX8/Oc/z5577tkcwwIAAECrVvXl5RdffHG++MUvZtKkSY1Be9CgQdlyyy2z5ZZbZtCgQUneD+GTJ0/OXnvtlYsuuqjaYQEAAKDVqyp0P/TQQznuuONSFEW6d++ec889N1OnTs3zzz+f+++/P/fff3+ef/75TJ06Neeee25qa2tTFEVOOOGEPPTQQ831HgAAAKBVqip0X3jhhWloaEhtbW3uv//+nHDCCendu/cC7Xr37p0TTjgh999/f2pra9PQ0JALL7ywmqEBAACg1asqdN97772pVCo58cQTs+666y6x/ac+9amceOKJKYoi99xzTzVDAwAAQKtXVeh+8803kyQ77LBDk/vMa/vWW29VMzQAAAC0elWF7tVWW61F+gIAAEBbUFXoHjZsWJLk7rvvbnKfu+66K0my4447VjM0AAAAtHpVhe7jjjsuK6ywQs4555w888wzS2z/zDPP5Nxzz82KK66YE044oZqhAQAAoNWrKnR/8pOfzF/+8pckyZZbbpmLLroob7zxxgLt3nzzzVx88cXZeuutkyR//vOf88lPfrKaoQEAAKDV61RN53mXiK+66qp59tlnc9xxx+X444/P4MGD06dPn1QqlUydOjUvvvhiiqJIkqy99to5//zzc/755y/0mJVKJbfffns1ZQEAAECrUFXovuuuu1KpVBpfF0WRoijy/PPP5/nnn19on+eeey7PPfdcYwifp1KppCiK+Y4HAAAAbVlVoXu77bYTkgEAAGARqj7TDQAAACxcVQupAQAAAIsmdAMAAEBJhG4AAAAoSVX3dH9QQ0NDnnzyybzwwgt5++23M3fu3CX2+frXv95cwwMAAECrU3XonjlzZn70ox/l17/+dV5//fUm96tUKkI3AAAA7VpVofudd97JDjvskEceeWSB524DAADAR11VoftHP/pRxo0blyTZcsstc9hhh+XTn/50evbsmQ4d3C4OAADAR1tVofsvf/lLKpVKdtttt1x//fWCNgAAAHxAVSl50qRJSZKjjz5a4AYAAIAPqSop9+nTJ0nSu3fvZikGAAAA2pOqQvfmm2+eJHn66aebpRgAAABoT6oK3cccc0yS5Oc//7nVywEAAOBDqgrdW2+9dc4999zcf//9+f/+v/8vb731VjOVBQAAAG1fVauXJ8nxxx+fj33sYzn00EPTv3//7LzzzvnEJz6Rbt26LbHvqaeeWu3wAAAA0GpVHbqnTZuWv//975k+fXoaGhpy/fXXN7mv0A0AAEB7VlXofv3117Pddtvl2WefdU83AAAAfEhV93SfddZZeeaZZ1IURb785S/njjvuyOuvv565c+emoaFhiV8AAADQnlV1pvuGG25IpVLJ1772tfz2t79trpoAAACgXajqTPekSZOSJAcffHCzFAMAAADtSVWhu3fv3kmS7t27N0sxAAAA0J5UFbq33XbbJMmECROapRgAAABoT6oK3ccdd1xqampywQUX5L333muumgAAAKBdqCp0b7zxxvn1r3+dZ555JsOHD88zzzzTXHUBAABAm1fV6uXzFlBbd911c99992XdddfNhhtumE984hPp1q3bYvtWKpVceeWV1QwPAAAArVpVofuqq65KpVJJ8n6IbmhoyKOPPppHH310sf2KohC6AQAAaPeqCt0DBgxoDN0AAADA/KoK3RMnTmymMgAAAKD9qWohNQAAAGDRhG4AAAAoSVWXly/KnDlz8uabbyZJVl555XTqVMowAAAA0Ko125nu//znPznqqKPyqU99Kl27dk2/fv3Sr1+/dO3aNZ/61Kdy9NFH58knn2yu4QAAAKDVa5bQffLJJ2fDDTfMZZddlqeffjoNDQ0piiJFUaShoSFPP/10Lr300nz605/OKaec0hxDAgAAQKtX9XXfRx11VC677LIURZEk+dSnPpUtttgi/fr1S5JMmTIlY8aMyZNPPpm5c+fm3HPPzYwZM3LxxRdXOzQAAAC0alWF7n/961+59NJLU6lUsu666+aKK67I1ltvvdC2DzzwQL75zW/m8ccfz89//vPss88+i2wLAAAA7UFVl5dffvnlSZLBgwfnX//612JD9FZbbZV77rkna621VpLkl7/8ZTVDAwAAQKtXVei+9957U6lUctJJJ6W2tnaJ7Wtra3PiiSemKIrce++91QwNAAAArV5VoXvKlClJkiFDhjS5z8Ybb5wkmTp1ajVDAwAAQKtXVeju2rVrkmTGjBlN7jOvbZcuXaoZGgAAAFq9qkL34MGDkyQ33nhjk/vMazvv3m4AAABor6oK3bvttluKosgll1yS22+/fYnt77zzzlxyySWpVCrZbbfdqhkaAAAAWr2qQvd3vvOd9OjRI/X19fnsZz+bI488Mo888kgaGhoa2zQ0NOSRRx7JkUcemV133TWzZ89Ojx498p3vfKfa2gEAAKBVq+o53b17986f//znfP7zn8/s2bPzi1/8Ir/4xS/SuXPn9OrVK5VKJa+//npmz56dJCmKIp07d861116bVVZZpVneAAAAALRWVZ3pTpLhw4fnwQcfzKabbpqiKFIURWbNmpVXXnklkydPzqxZsxq3b7rppnnooYcybNiw5qgdAAAAWrWqznTPs9FGG2XMmDF5+OGHc9ttt2XChAl54403kiS9evXK+uuvn2HDhmWzzTZrjuEAAACgTWiW0D3PZpttJlgDAADA/1/Vl5cDAAAAC7dUZ7pnzZqVp59+OklSW1ubgQMHNrnvSy+9lOnTpydJPvWpT6WmpmZphgYAAIA2Z6nOdJ955pkZMmRINt988/zvf/9bqoH+97//ZbPNNsuQIUNy7rnnLlVfAAAAaIuaHLqnT5+en/70p0mSk08+Odtss81SDbTNNtvklFNOSVEUOe+88/LOO+8sXaUAAADQxjQ5dF9zzTV59913s8oqq+T4449fpsFOOOGErLrqqpkxY0auueaaZToGAAAAtBVNDt2jR49OpVLJF7/4xay44orLNFi3bt2y1157pSiKjBo1apmOAQAAAG1Fk0P3v//97yTJsGHDqhpwxx13TJI88sgjVR0HAAAAWrsmh+5XX301SbLmmmtWNeAaa6yRJJk2bVpVxwEAAIDWrsmhe9asWUmSzp07VzXgvP6zZ8+u6jgAAADQ2jU5dPfu3TtJMnXq1KoGnHeGe5VVVqnqOAAAANDaNTl09+/fP0ly//33VzXgv/71r/mOBwAAAO1Vk0P3DjvskKIo8qc//Slz5sxZpsHq6+vzxz/+MZVKJTvssMMyHQMAAADaiiaH7r322itJMnHixPzoRz9apsF+/OMfZ+LEifMdDwAAANqrJofuTTbZJLvvvnuKosiZZ56Zs88+O0VRNHmgs846Kz/84Q9TqVSy++67Z5NNNlmmggEAAKCtaHLoTpJLL700ffv2TZJ8//vfz6abbprf/va3jY8T+7BXX301V111VTbZZJP84Ac/SJL07ds3l156aZVlAwAAQOvXaWkaDxgwIDfeeGP22GOPTJ06NePHj8/BBx+c5P3nb/fp0ycrrrhiZsyYkalTp2by5MmNfYuiSN++fXPjjTdaRA0AAICPhKUK3Umy6aabZvz48TnssMNy4403Nm6fNGlSJk2aNF/bD15+/vnPfz6XX35545lyAAAAaO+WOnQn718ifv311+eJJ57IyJEjc/fdd+exxx5LfX19Y5uamppsuOGG2X777XPggQdm/fXXb7aiAQAAoC1YptA9z3rrrZcLLrig8fXbb7+dt99+O927d0/37t2rLg4AAADasqpC94cJ2wAAAPD/LNXq5QAAAEDTCd0AAABQknYduidOnJhDDjkkgwcPzgorrJCPfexjOe200zJ79uz52j322GPZdttt07Vr1/Tv3z/nnXdeC1UMAABAe9Ks93S3Nk899VQaGhpy+eWXZ+21186ECRNy6KGHZsaMGY0LwNXV1WX48OEZNmxYfvnLX+bxxx/PwQcfnJ49e+awww5r4XcAAABAW9auQ/euu+6aXXfdtfH1Wmutlaeffjq/+MUvGkP3H/7wh8yePTu/+c1v0rlz56y33noZP358LrzwQqEbAACAqrTry8sXZvr06enVq1fj6wceeCDbbbddOnfu3Lhtl112ydNPP50333yzJUoEAACgnWjXZ7o/7Lnnnssll1wy37PFp0yZksGDB8/Xrm/fvo37Vl555YUea9asWZk1a1bj67q6uiRJfX196uvrm7t0Wrl5n7nPnuZkXlEG84oymFeUwbyiDM05r5p6jDYZuk866aSce+65i23zn//8J+uss07j60mTJmXXXXfN3nvvnUMPPbTqGs4+++ycccYZC2wfNWpUunXrVvXxaZtGjx7d0iXQDplXlMG8ogzmFWUwryhDc8yrmTNnNqldpSiKourRlrNXX301r7/++mLbrLXWWo2XjE+ePDlDhw7NlltumauuuiodOvy/q+q//vWvp66uLtddd13jtjvvvDM77rhj3njjjaU6092/f/+89tpr6dGjRxXvjraovr4+o0ePzs4775yampqWLod2wryiDOYVZTCvKIN5RRmac17V1dWld+/emT59+mIzYJs8073qqqtm1VVXbVLbSZMmZYcddsgmm2ySkSNHzhe4k2SrrbbK9773vdTX1zf+0EePHp1PfvKTiwzcSdKlS5d06dJlge01NTV+KXyE+fwpg3lFGcwrymBeUQbzijI0x7xqav92vZDapEmTMnTo0AwYMCAXXHBBXn311UyZMiVTpkxpbLPvvvumc+fOOeSQQ/LEE0/kmmuuycUXX5xjjz22BSsHAACgPWjSme611lqr2QeuVCp5/vnnm/24HzR69Og899xzee6557LmmmvOt2/eVfW1tbUZNWpURowYkU022SS9e/fOqaee6nFhAAAAVK1JoXvixInNPnClUmn2Y37YgQcemAMPPHCJ7TbccMPce++9pdcDAADAR0uTQvcBBxxQdh0AAADQ7jQpdI8cObLsOgAAAKDdadcLqQEAAEBLEroBAACgJEI3AAAAlKRJ93QvjYkTJ+a1117Lu+++2/hYrkXZbrvtmnt4AAAAaDWaJXQ//fTTOeuss3LDDTekrq6uSX0qlUrmzJnTHMMDAABAq1R16L7uuuuy33775b333lvimW0AAAD4KKkqdP/3v//N1772tbz77rtZY401csIJJ6Rbt2457LDDUqlUctttt+WNN97I2LFj8/vf/z6TJ0/OZz7zmZx++unp2LFjc70HAAAAaJWqCt0/+9nPMnPmzHTv3j0PPfRQVl999TzxxBON+3fYYYckyV577ZVTTz01hxxySK655ppceeWV+cMf/lBd5QAAANDKVbV6+W233ZZKpZJvfetbWX311RfbdoUVVsj//d//ZciQIbn66qvz17/+tZqhAQAAoNWrKnRPnDgxSbL11ls3bqtUKo3ff3ihtA4dOuToo49OURT5zW9+U83QAAAA0OpVFbpnzJiRJOnfv3/jtm7dujV+P3369AX6rLfeekmSRx99tJqhAQAAoNWrKnTX1tYmSd57773Gbausskrj988///wCfeYF8ddee62aoQEAAKDVqyp0f/KTn0ySvPDCC43bunfvnoEDByZJRo0atUCf0aNHJ0l69uxZzdAAAADQ6lUVurfaaqskyYMPPjjf9s997nMpiiLnn39+7rzzzsbtf/7zn3PxxRenUqlkm222qWZoAAAAaPWqCt277bZbiqLI3/72t8ydO7dx+7zndb/zzjsZNmxYVl111XTv3j1f/epX895776VDhw454YQTqi4eAAAAWrOqQvfQoUNz2mmn5aCDDsqkSZMatw8YMCDXXnttamtrUxRFXn/99cyYMSNFUaRLly751a9+lS233LLq4gEAAKA161RN50qlktNOO22h+z772c/m2WefzV/+8pc88cQTmTNnTj7+8Y/nK1/5StZYY41qhgUAAIA2oarQvSSrrLJKDj/88DKHAAAAgFarqsvLAQAAgEVr9jPdRVHkhRdeyBtvvJEk6dWrV9Zaa61UKpXmHgoAAABatWYL3f/85z9z2WWX5a677sqMGTPm29etW7cMHTo03/rWt/LZz362uYYEAACAVq3qy8tnzpyZvfbaK7vvvntuvvnmvPPOOymKYr6vGTNm5B//+Ec+97nP5Ytf/OICoRwAAADao6rOdDc0NGS33XbLvffem6IoUlNTk+HDh2fzzTdP3759kyRTp07Nww8/nFGjRmX27Nm54YYbsttuu+Wuu+5yyTkAAADtWlWh+/LLL88999yTSqWSXXbZJb/+9a8X+TiwSZMm5dBDD80///nP3HffffnlL3+ZI444oprhAQAAoFWr6vLy3/72t0mSzTbbLDfffPNin7+9xhpr5MYbb8zmm2+eoiga+wIAAEB7VVXo/s9//pNKpZJjjjkmHTos+VAdO3bMscce29gXAAAA2rOqQve8e7I/8YlPNLnPxz/+8fn6AgAAQHtVVej+2Mc+liSZNm1ak/vMazuvLwAAALRXVYXur371qymKIr/73e+a3Od3v/tdKpVK9tlnn2qGBgAAgFavqtB99NFHZ+ONN87VV1+d8847b4ntzz///PzpT3/KkCFD8p3vfKeaoQEAAKDVq+qRYVOmTMmvf/3rHH744Tn55JPzpz/9KQcccEA222yz9OnTJ5VKpfE53b///e8zfvz4bLbZZrniiisyZcqURR53wIAB1ZQFAAAArUJVoXvQoEHzLYj22GOP5bjjjltsn7Fjx2bjjTde5P5KpZI5c+ZUUxYAAAC0ClWF7iQpiqI56gAAAIB2p6rQPXLkyOaqAwAAANqdqkL3AQcc0Fx1AAAAQLtT1erlAAAAwKIJ3QAAAFASoRsAAABK0qR7un/4wx82fn/qqacudPuy+OCxAAAAoL1pUug+/fTTG5/H/cGg/MHty0LoBgAAoD1r8urli3oet+d0AwAAwMI1KXQ3NDQs1XYAAADAQmoAAABQGqEbAAAASiJ0AwAAQEmqCt1TpkzJwQcfnIMPPjiTJk1aYvtJkybl4IMPziGHHJI33nijmqEBAACg1asqdP/+97/PVVddlfHjx2eNNdZYYvs11lgj48ePz1VXXZX/+7//q2ZoAAAAaPWqCt2jRo1KpVLJl7/85Sb32WeffVIURW655ZZqhgYAAIBWr6rQPWHChCTJ5ptv3uQ+m266aZLkscceq2ZoAAAAaPWqCt2vv/56kmTVVVdtcp/evXvP1xcAAADaq6pC90orrZQkmT59epP71NXVJUk6d+5czdAAAADQ6lUVutdcc80kyQMPPNDkPv/617+SpEkLrwEAAEBbVlXoHjp0aIqiyCWXXNJ4Bntx6urq8vOf/zyVSiVDhw6tZmgAAABo9aoK3YcffngqlUpeeeWV7L777pk6deoi206ZMiW77757Jk+enEqlksMPP7yaoQEAAKDV61RN5/XWWy/f/va3c9FFF+X+++/P2muvnX322SfbbrttVltttSTJK6+8knvuuSd//vOfM3PmzFQqlYwYMSIbbbRRc9QPAAAArVZVoTtJLrjggkyfPj0jR47MjBkzMnLkyIwcOXKBdkVRJEm+8Y1v5KKLLqp2WAAAAGj1qrq8PEk6dOiQK6+8Mtddd1222mqrJO8H7A9+Jck222yTG264IVdccUUqlUq1wwIAAECrV/WZ7nk+//nP5/Of/3zeeOONjB8/Pq+99lqS95/LPWTIkKy88srNNRQAAAC0Cc0Wuufp1atXdtxxx+Y+LAAAALQ5VV9eDgAAACyc0A0AAAAlaZbLy+fMmZObb7459957b1544YW8/fbbmTt37mL7VCqV3H777c0xPAAAALRKVYfu++67L/vvv39efvnlxm3zVixfmEqlkqIorGAOAABAu1dV6H7qqaey66675t13301RFOncuXM+/vGPp1evXunQwZXrAAAAfLRVFbrPOuuszJw5Mx07dswZZ5yRo48+OiuttFJz1QYAAABtWlWh+4477kilUsm3v/3tnHLKKc1VEwAAALQLVV0D/tprryVJvvjFLzZLMQAAANCeVBW6V1111STJCius0CzFAAAAQHtSVej+zGc+kySZMGFCsxQDAAAA7UlVofvYY49Nx44dc/HFF2fOnDnNVRMAAAC0C1WF7s022ywXXXRRHn300XzpS19qvMcbAAAAqHL18h/+8IdJks033zw33XRTBg4cmJ133jnrrLNOunXrtsT+p556ajXDAwAAQKtWVeg+/fTTU6lUkiSVSiXvvvtubrzxxtx4441N6i90AwAA0J5VFbqTpCiKxb4GAACAj6qqQndDQ0Nz1QEAAADtTlULqQEAAACLJnQDAABASYRuAAAAKInQDQAAACVp0kJqa621VpL3Hwv2/PPPL7B9WXz4WAAAANDeNCl0T5w4MUkan8n94e3L4sPHAgAAgPamSaH7gAMOWKrtAAAAQBND98iRI5dqOwAAAGAhNQAAAChNk850L8rBBx+cJPnsZz+bvffeu1kKAgAAgPaiqtD929/+Nkmyzz77NEsxAAAA0J5UdXn5qquumiTp27dvsxQDAAAA7UlVoXvddddNkrz00kvNUgwAAAC0J1WF7q997WspiqLxMnMAAADg/6kqdB900EHZaaedcv311+f0009PURTNVRcAAAC0eVUtpHbvvffm+OOPz6uvvpozzzwz11xzTfbZZ59suOGGWXnlldOxY8fF9t9uu+2qGR4AAABatapC99ChQ1OpVBpfP/PMMznzzDOb1LdSqWTOnDnVDA8AAACtWlWhO4lLygEAAGARqgrdd955Z3PVAQAAAO1OVaF7++23b646AAAAoN2pavVyAAAAYNGW6Uz3zTffnH/+85956aWXMnfu3Ky++uoZOnRovvKVr6Smpqa5awQAAIA2aalC99SpU7PnnntmzJgxC+z7zW9+k1NPPTXXXXddNthgg2YrEAAAANqqJl9ePnfu3Hz+85/PQw89lKIoFvr14osvZpdddslrr71WZs0AAADQJjQ5dP/5z3/Oww8/nEqlkrXXXjtXXnllHn/88Tz11FO59tprs+WWWyZ5/2z4T37yk9IKBgAAgLZiqUJ3kgwaNChjxozJQQcdlPXWWy+f+MQnstdee+Xee+/N9ttvn6Iocu2115ZWMAAAALQVTQ7d//73v1OpVHLcccelZ8+eC+zv2LFjzjjjjCTJiy++mLfffrvZimwOs2bNykYbbZRKpZLx48fPt++xxx7Ltttum65du6Z///4577zzWqZIAAAA2pUmh+5XX301SbLpppsuss0H97W2+7q/+93vZvXVV19ge11dXYYPH56BAwdm3LhxOf/883P66afniiuuaIEqAQAAaE+avHr5u+++m0qlkpVWWmmRbbp169b4/XvvvVddZc3olltuyahRo/LXv/41t9xyy3z7/vCHP2T27Nn5zW9+k86dO2e99dbL+PHjc+GFF+awww5roYoBAABoD5p8pntpFUVR1qGXytSpU3PooYfm97///Xx/FJjngQceyHbbbZfOnTs3bttll13y9NNP580331yepQIAANDOLNVzutuaoihy4IEH5pvf/GY23XTTTJw4cYE2U6ZMyeDBg+fb1rdv38Z9K6+88kKPPWvWrMyaNavxdV1dXZKkvr4+9fX1zfQOaCvmfeY+e5qTeUUZzCvKYF5RBvOKMjTnvGrqMZY6dF922WXp06dPs7Q79dRTl3b4JMlJJ52Uc889d7Ft/vOf/2TUqFF5++23c/LJJy/TOItz9tlnNy4c90GjRo1a6Bl1PhpGjx7d0iXQDplXlMG8ogzmFWUwryhDc8yrmTNnNqldpWjideAdOnRIpVKpqqgPmzt37jL1e/XVV/P6668vts1aa62Vr3zlK7nxxhvnq3vu3Lnp2LFj9ttvv/z2t7/N17/+9dTV1eW6665rbHPnnXdmxx13zBtvvLFUZ7r79++f1157LT169Fim90XbVV9fn9GjR2fnnXdOTU1NS5dDO2FeUQbzijKYV5TBvKIMzTmv6urq0rt370yfPn2xGXCpznQ3533a1QT4VVddNauuuuoS2/3sZz/Lj370o8bXkydPzi677JJrrrkmW2yxRZJkq622yve+973U19c3/tBHjx6dT37yk4sM3EnSpUuXdOnSZYHtNTU1fil8hPn8KYN5RRnMK8pgXlEG84oyNMe8amr/JofuO++8c5mLaSkDBgyY7/W8ldc/9rGPZc0110yS7LvvvjnjjDNyyCGH5MQTT8yECRNy8cUX56c//elyrxcAAID2pcmhe/vtty+zjhZTW1ubUaNGZcSIEdlkk03Su3fvnHrqqR4XBgAAQNXa9erlHzZo0KCFXiK/4YYb5t57722BigAAAGjPSntONwAAAHzUCd0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlOQjEbpvvvnmbLHFFllhhRWy8sorZ88995xv/8svv5zdd9893bp1S58+fXLCCSdkzpw5LVMsAAAA7Uanli6gbH/9619z6KGH5qyzzsqOO+6YOXPmZMKECY37586dm9133z39+vXL/fffn1deeSVf//rXU1NTk7POOqsFKwcAAKCta9ehe86cOfn2t7+d888/P4ccckjj9nXXXbfx+1GjRuXJJ5/Mbbfdlr59+2ajjTbKmWeemRNPPDGnn356Onfu3BKlAwAA0A6069D9yCOPZNKkSenQoUOGDBmSKVOmZKONNsr555+f9ddfP0nywAMPZIMNNkjfvn0b++2yyy454ogj8sQTT2TIkCELPfasWbMya9asxtd1dXVJkvr6+tTX15f4rmiN5n3mPnuak3lFGcwrymBeUQbzijI057xq6jHadeh+4YUXkiSnn356LrzwwgwaNCg/+clPMnTo0DzzzDPp1atXpkyZMl/gTtL4esqUKYs89tlnn50zzjhjge2jRo1Kt27dmvFd0JaMHj26pUugHTKvKIN5RRnMK8pgXlGG5phXM2fObFK7Nhm6TzrppJx77rmLbfOf//wnDQ0NSZLvfe972WuvvZIkI0eOzJprrplrr702hx9++DLXcPLJJ+fYY49tfF1XV5f+/ftn+PDh6dGjxzIfl7apvr4+o0ePzs4775yampqWLod2wryiDOYVZTCvKIN5RRmac17Nu9p5Sdpk6D7uuONy4IEHLrbNWmutlVdeeSXJ/Pdwd+nSJWuttVZefvnlJEm/fv0yZsyY+fpOnTq1cd+idOnSJV26dFlge01NjV8KH2E+f8pgXlEG84oymFeUwbyiDM0xr5rav02G7lVXXTWrrrrqEtttsskm6dKlS55++ul85jOfSfL+XzYmTpyYgQMHJkm22mqr/PjHP860adPSp0+fJO9fatCjR4/5wjoAAAAsrTYZupuqR48e+eY3v5nTTjst/fv3z8CBA3P++ecnSfbee+8kyfDhw7Puuutm//33z3nnnZcpU6bk+9//fkaMGLHQM9kAAADQVO06dCfJ+eefn06dOmX//ffPu+++my222CJ33HFHVl555SRJx44dc9NNN+WII47IVlttlRVXXDEHHHBAfvjDH7Zw5QAAALR17T5019TU5IILLsgFF1ywyDYDBw7MP/7xj+VYFQAAAB8FHVq6AAAAAGivhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAknRq6QL4aJo+sz4TJk/P9HfrU7tCTdZfvTa13WpauiwAAIBmJXSz3D0xeXpue3JaGoqicdvYiW9m2Lp9st7qtS1YGQAAQPNyeTnL1fSZ9QsE7iRpKIrc9uS0TJ9Z30KVAQAAND+hm+VqwuTpCwTueRqKIhMmT1/OFQEAAJRH6Ga5mv7u4s9k1y1hPwAAQFsidLNc1a6w+MXSeixhPwAAQFsidLNcrb96bTpUKgvd16FSyfoWUgMAANoRoZvlqrZbTYat22eB4N2hUsnO6/b12DAAAKBd8cgwlrv1Vq/Nmj27ZcLk6al7tz49PKcbAABop4RuWkRtt5pss3bvli4DAACgVC4vBwAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFCSTi1dQHtRFEWSpK6uroUroSXU19dn5syZqaurS01NTUuXQzthXlEG84oymFeUwbyiDM05r+Zlv3lZcFGE7mby9ttvJ0n69+/fwpUAAACwvLz99tupra1d5P5KsaRYTpM0NDRk8uTJ6d69eyqVSkuXw3JWV1eX/v3757///W969OjR0uXQTphXlMG8ogzmFWUwryhDc86roijy9ttvZ/XVV0+HDou+c9uZ7mbSoUOHrLnmmi1dBi2sR48e/k+BZmdeUQbzijKYV5TBvKIMzTWvFneGex4LqQEAAEBJhG4AAAAoidANzaBLly457bTT0qVLl5YuhXbEvKIM5hVlMK8og3lFGVpiXllIDQAAAEriTDcAAACUROgGAACAkgjdAAAAUBKhG6owceLEHHLIIRk8eHBWWGGFfOxjH8tpp52W2bNnz9fusccey7bbbpuuXbumf//+Oe+881qoYtqSSy+9NIMGDUrXrl2zxRZbZMyYMS1dEm3E2Wefnc022yzdu3dPnz59sueee+bpp5+er817772XESNGZJVVVslKK62UvfbaK1OnTm2himmLzjnnnFQqlXznO99p3GZesSwmTZqUr33ta1lllVWywgorZIMNNsjYsWMb9xdFkVNPPTWrrbZaVlhhhQwbNizPPvtsC1ZMazd37tz84Ac/mO+/0c8888x8cDmz5TmvhG6owlNPPZWGhoZcfvnleeKJJ/LTn/40v/zlL3PKKac0tqmrq8vw4cMzcODAjBs3Lueff35OP/30XHHFFS1YOa3dNddck2OPPTannXZaHnnkkXz605/OLrvskmnTprV0abQBd999d0aMGJEHH3wwo0ePTn19fYYPH54ZM2Y0tjnmmGNy44035tprr83dd9+dyZMn50tf+lILVk1b8vDDD+fyyy/PhhtuON9284ql9eabb2abbbZJTU1Nbrnlljz55JP5yU9+kpVXXrmxzXnnnZef/exn+eUvf5mHHnooK664YnbZZZe89957LVg5rdm5556bX/ziF/n5z3+e//znPzn33HNz3nnn5ZJLLmlss1znVQE0q/POO68YPHhw4+vLLrusWHnllYtZs2Y1bjvxxBOLT37yky1RHm3E5ptvXowYMaLx9dy5c4vVV1+9OPvss1uwKtqqadOmFUmKu+++uyiKonjrrbeKmpqa4tprr21s85///KdIUjzwwAMtVSZtxNtvv118/OMfL0aPHl1sv/32xbe//e2iKMwrls2JJ55YfOYzn1nk/oaGhqJfv37F+eef37jtrbfeKrp06VL86U9/Wh4l0gbtvvvuxcEHHzzfti996UvFfvvtVxTF8p9XznRDM5s+fXp69erV+PqBBx7Idtttl86dOzdu22WXXfL000/nzTffbIkSaeVmz56dcePGZdiwYY3bOnTokGHDhuWBBx5owcpoq6ZPn54kjb+bxo0bl/r6+vnm2DrrrJMBAwaYYyzRiBEjsvvuu883fxLzimVzww03ZNNNN83ee++dPn36ZMiQIfnVr37VuP/FF1/MlClT5ptXtbW12WKLLcwrFmnrrbfO7bffnmeeeSZJ8uijj+a+++7LZz/72STLf151avYjwkfYc889l0suuSQXXHBB47YpU6Zk8ODB87Xr27dv474PXj4FSfLaa69l7ty5jfNknr59++app55qoapoqxoaGvKd73wn22yzTdZff/0k7//u6dy5c3r27Dlf2759+2bKlCktUCVtxdVXX51HHnkkDz/88AL7zCuWxQsvvJBf/OIXOfbYY3PKKafk4YcfztFHH53OnTvngAMOaJw7C/v/RPOKRTnppJNSV1eXddZZJx07dszcuXPz4x//OPvtt1+SLPd55Uw3LMRJJ52USqWy2K8Ph59JkyZl1113zd57751DDz20hSoHmN+IESMyYcKEXH311S1dCm3cf//733z729/OH/7wh3Tt2rWly6GdaGhoyMYbb5yzzjorQ4YMyWGHHZZDDz00v/zlL1u6NNqwP//5z/nDH/6QP/7xj3nkkUfy29/+NhdccEF++9vftkg9znTDQhx33HE58MADF9tmrbXWavx+8uTJ2WGHHbL11lsvsEBav379Fli5dd7rfv36NU/BtCu9e/dOx44dFzpvzBmWxpFHHpmbbrop99xzT9Zcc83G7f369cvs2bPz1ltvzXdW0hxjccaNG5dp06Zl4403btw2d+7c3HPPPfn5z3+eW2+91bxiqa222mpZd91159v2qU99Kn/961+T/L//Vpo6dWpWW221xjZTp07NRhtttNzqpG054YQTctJJJ+X/+//+vyTJBhtskJdeeilnn312DjjggOU+r5zphoVYddVVs8466yz2a9492pMmTcrQoUOzySabZOTIkenQYf5/VltttVXuueee1NfXN24bPXp0PvnJT7q0nIXq3LlzNtlkk9x+++2N2xoaGnL77bdnq622asHKaCuKosiRRx6Zv//977njjjsWuMVlk002SU1NzXxz7Omnn87LL79sjrFIO+20Ux5//PGMHz++8WvTTTfNfvvt1/i9ecXS2mabbRZ4pOEzzzyTgQMHJkkGDx6cfv36zTev6urq8tBDD5lXLNLMmTMX+G/yjh07pqGhIUkLzKtmX5oNPkL+97//FWuvvXax0047Ff/73/+KV155pfFrnrfeeqvo27dvsf/++xcTJkworr766qJbt27F5Zdf3oKV09pdffXVRZcuXYqrrrqqePLJJ4vDDjus6NmzZzFlypSWLo024Igjjihqa2uLu+66a77fSzNnzmxs881vfrMYMGBAcccddxRjx44tttpqq2KrrbZqwappiz64enlRmFcsvTFjxhSdOnUqfvzjHxfPPvts8Yc//KHo1q1b8X//93+Nbc4555yiZ8+exfXXX1889thjxRe+8IVi8ODBxbvvvtuCldOaHXDAAcUaa6xR3HTTTcWLL75Y/O1vfyt69+5dfPe7321sszznldANVRg5cmSRZKFfH/Too48Wn/nMZ4ouXboUa6yxRnHOOee0UMW0JZdcckkxYMCAonPnzsXmm29ePPjggy1dEm3Eon4vjRw5srHNu+++W3zrW98qVl555aJbt27FF7/4xfn+YAhN8eHQbV6xLG688cZi/fXXL7p06VKss846xRVXXDHf/oaGhuIHP/hB0bdv36JLly7FTjvtVDz99NMtVC1tQV1dXfHtb3+7GDBgQNG1a9dirbXWKr73ve/N9wjf5TmvKkVRFM1//hwAAABwTzcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwDN4sADD0ylUsmgQYNaupRFqlQqqVQqOf3001u6lMVqCz9LAKBphG6Aduquu+5qDJkf/urWrVsGDhyYPffcM3/84x8zZ86cli6Xko0dOzYnn3xyttxyy6yxxhrp0qVLevTokY997GP58pe/nMsvvzxvvfVWS5fJR8w777yTe+65JxdccEG+8pWvZPDgwY2/p/zRCWgvOrV0AQAsf++++25efvnlvPzyy7n++utz0UUX5YYbbki/fv1aujSa2UsvvZQjjzwyN9100wL7Zs+enbfffjsvvPBC/vrXv+aYY47JMccck+9///tZYYUVWqBaWrtBgwblpZdeygEHHJCrrrqq6uPtscceueuuu6o+DkBrJnQDfAQcccQR+da3vtX4+p133snYsWPzk5/8JBMnTszDDz+cL3zhC3nwwQdTqVSWaYyrrrqqWf4jvExFUbR0CcvV2LFj87nPfS5Tp05N8n5g+upXv5qtt946ffv2zezZs/O///0vt912W/7+97/n9ddfz1lnnZW99947G220UcsWz0fCB/9N9urVK5tuumnuv//+vPPOOy1YFUDzEroBPgL69OmT9ddff75tW265Zfbbb79svvnmee655zJmzJjcdNNN2WOPPVqoSprTlClT5gvc3//+9/ODH/wgnTt3XqDtPvvskwsvvDA/+clPctZZZy3vUvkI23fffXP44Ydns802y9prr53k/T8OCd1Ae+KeboCPsJVXXjknn3xy4+t//vOfLVgNzenwww9vDNxnnnlmzjzzzIUG7nm6d++e008/Pbfffntqa2uXV5l8xB122GH56le/2hi4AdojoRvgI27zzTdv/P6ll15q/P6DC7HdddddaWhoyG9+85vssMMO6du3bzp06JADDzywsf2SVtz+8MrhDz/8cL761a9mzTXXTJcuXbLGGmtk//33z3/+858m1T1hwoQcddRR2WCDDbLyyiunpqYm/fr1y7Bhw3LeeefllVdeWWINH3TVVVc17p84cWJmzZqVCy64IBtvvHFqa2vTo0ePbLHFFrnssssyd+7cRdbV0NCQO+64I8cff3y22Wab9O7dOzU1NenZs2c22mijHH/88Xn55Zeb9B6X1YQJE3LDDTckSTbaaKP5/rCyJNtuu20GDx680H2vvvpqvv/972fIkCHp2bNnunbtmkGDBmX//ffPfffdt9jjDho0KJVKpXHOPPLII9lvv/3Sv3//rLDCCll77bVz7LHH5rXXXpuv3/3335+99947AwYMSNeuXfOxj30sJ554Yt5+++1FjjV06NBUKpUMHTo0SfL000/nsMMOy+DBg9O1a9esttpq+cpXvpIHH3ywST+T++67L/vvv38GDRqUrl27pmfPnhkyZEi+//3v59VXX11kvw//G0qSP//5z9lpp52y6qqrZoUVVsgnP/nJfPe7380bb7zRpFquu+66+X4ePXv2zKabbpozzjgjb7755iL7ffjf51tvvZVTTz016623XlZcccX07Nkz2223Xf7whz8stP+8n+m83xG//e1vF1igcd7PG4APKQBol+68884iSZGkOO200xbZ7qmnnmpst+uuuy60/y233FIMGzas8fW8rwMOOKCx/QEHHFAkKQYOHLjQcT5Yy6WXXlp06tRpgeMlKbp161bcfffdi6x3zpw5xTHHHFNUKpWF9l9YbQur4cNGjhzZuP+RRx4pNtlkk0Uee7vttivefvvthdZ32mmnLbauee/xb3/72yLf45J+lkty7LHHNo515ZVXLtMxPuzWW28tevTosdj3NWLEiGLu3LkL7T9w4MDGz+V3v/td0blz54Ue4xOf+ETxyiuvFEVRFOeff/4iP+eNN954kZ/B9ttvXyQptt9+++If//hHseKKKy70GB06dCh++tOfLvI9z507txgxYsRi33NtbW0xatSohfb/4L+h22+/vfja1762yOOsvfbaje97Yd54441ixx13XGwtffr0KR544IGF9v/gnHrqqaeKQYMGLfZzXNTPdHFf22+//SLrXxrz5sqyzn+A1saZboCPuMcff7zx+9VXX32hbU488cTcdttt+fznP5+//e1vGTduXP7xj3/ks5/97FKPd+utt+aoo47Keuutl9/85jd5+OGHc8899+SYY45Jhw4dMnPmzOy///6ZPXv2Qvsfdthh+elPf5qiKLLaaqvlxz/+ce6888488sgjufXWW3PmmWfm05/+9FLX9UGHH354xo0bl3322Sf/+Mc/Mnbs2Pzxj3/MZpttliS55557sv/++y+075w5c7LaaqvlW9/6Vn7/+9/nX//6V8aNG5frrrsu3/3ud7PSSitl5syZ2XfffZt8Vn9p3X333Y3f77777lUfb/z48dljjz1SV1eXmpqaHHPMMbnzzjszZsyYXH755Y1nxi+99NIlnlV/9NFH841vfCNrr7124+d/xx135Gtf+1qS5Jlnnsnxxx+fv/3tbznhhBOyxRZb5A9/+EPGjh2bf/7zn9ltt92SvH+m/Ec/+tFix5o8eXL23XffdOrUKWeddVbuv//+3H///fnxj3+cHj16pKGhIcccc0yuu+66hfY/6aSTcumllyZJBg8enF/+8pcZM2ZM7rzzzhxzzDGpqanJ9OnT87nPfS6PPvroYmv5wQ9+kP/7v//LnnvuOd+/oXmfz3PPPZdjjjlmoX1nzZqVYcOG5Y477kjHjh2z//77509/+lMefPDB3Hvvvfnxj3+cVVZZJdOmTctuu+023xUrHzZz5szsscceef311/P9738/d911V8aOHZtf/epXWXPNNZO8/zneeuut8/UbOXJkHn/88cbfEV/4whfy+OOPz/c1cuTIxf4MAD6yWjr1A1COppzprq+vL7bccsvGdr/73e8W2j9J8f3vf3+x4zX1THeSYrfdditmzZq1QJsf/ehHjW0Wdib4+uuvb9y/1VZbFW+++eYi63n55ZcXWcOSznQnKc4666wF2tTX1xe77LJLY5ubb755gTYvvvhiMXv27EXW9d///rdYY401iiTF1772tYW2qfZMd01NTZGkWGONNZap/4dtttlmRZKiY8eOxa233rrA/jfeeKNYd911G88eT5gwYYE2885eJim23nrrYsaMGQu0+fKXv9w4Tq9evYq99tqrmDNnznxt5syZ0zhnV1lllaK+vn6B43zwrGxtbW3x5JNPLtBmwoQJjWfu11hjjQU+s8cee6zo0KFDkaRYf/31FzrXbrnllsY2m2+++QL7P/xv6Ec/+tECbRoaGorhw4cXSYpOnToV06ZNW6DNKaecUiQpevbsWYwdO3aB/UVRFBMnTixWW221Ikmx7777LrB/3pya9zNZ2Gf07LPPFl27di2SFJ///OcXOs4Hr1goizPdQHvjTDfAR9CMGTNy9913Z+edd268r3XgwIH5yle+stD2n/jEJxZ6H/Sy6Nq1a0aOHLnQRb2OPvroxu333nvvAvvPOeecJEm3bt3yl7/8JT179lzkOP3791/mGjfccMOcdNJJC2zv1KlTfv3rX6empiZJctllly3QZtCgQY37F2bNNdfMCSeckCS54YYbmv0xZnV1damvr0/y/qr11RozZkwefvjhJMmhhx6a4cOHL9Bm5ZVXzhVXXJHk/XvaF/ZzmadSqeTXv/51unXrtsC+eY+1mzt3bt57771cccUV6dix43xtOnbsmMMOOyxJ8vrrr+fJJ59cbP0/+MEP8qlPfWqB7eutt16+973vJUkmTZqU66+/fr79v/jFL9LQ0JAk+fWvf73Qubbrrrvm4IMPTjL/z2lhNtlkk5xyyikLbK9UKjn22GOTvH+VxAMPPDDf/nfeeafxbPuZZ56ZTTbZZKHHHzhwYH7wgx8kSa699trMmDFjkbWceeaZWW+99RbYvvbaa2fPPfdMkiXeow9A0wndAB8BZ5xxxnwLHq200koZOnRo4+JOffr0yXXXXZcuXbostP8+++yzQPhZVjvvvPMiw2D37t3z8Y9/PEnywgsvzLfv9ddfb/wDwT777LPIS+GbwwEHHLDI55WvueaajcHzrrvuWuyiasn7IfjFF1/ME088kQkTJmTChAmNgXPevub0wQXGVlxxxaqPd9tttzV+f8ghhyyy3TbbbNMYbj/Y58M23HDDhYbgJPPdFrDzzjunV69eS2z34XnyQZVKJQcccMAi9x900EGNn/OHa573er311ssWW2yxyGMceuihC/RZmH333XeRc+qDQfrD7+fuu+/O9OnTkyRf/vKXF3n8JNluu+2SJPX19Rk3btxC21Qqley7776LPMa8Wt5444289dZbix0PgKYRugE+wgYPHpwTTjghjz/+eDbaaKNFtttwww2bbcx11llnsfvnBa0Pr049fvz4xrPC2267bbPVszDz7t1elHkrvs+YMWOhoe+ll17KUUcdlUGDBqW2tjZrrbVW1l9//WywwQbZYIMNGs/UJllgte5qde/evfH7xZ3tbKoJEyYkSTp37rzYOZKkMZw+++yzi7wn/xOf+MQi+3/wbHJT2y1uFfPBgwend+/ei9y/6qqrNq7m/cG1DWbNmpVnn302SRYbuJNkyJAhjVc2zPtZLczi5v0H/7jw4fczduzYxu9XW221BVYM/+DX+uuv39h2ypQpCx2rd+/eWWWVVZapFgCWTaeWLgCA8h1xxBGNl+5WKpV07do1vXv3bvLzmFdeeeVmq2VhlxV/UIcO7/89+MNnkD8YTldbbbVmq2dhlnRZdt++fRu///Cjnm655ZZ8+ctfzsyZM5s01rvvvrv0BS5Gjx49UlNTk/r6+sbndFdj3vvr1atXOnVa/H829OvXL0lSFEXefPPN+X5O8yzu85/32S9Nu8VdadCUy+v79u2bF198cb7P8YOP3lrSMWpqarLKKqtkypQpi33s17K+n2nTpi12/EVZ1Pxr6r+/hdUCwLIRugE+Avr06TPfWbCl1VyXlrcVi7oMeElee+217Lvvvpk5c2ZWWmmlHH/88dlll13ysY99LLW1tY33q99xxx3ZaaedkqTZ7+lO3r8yYdy4cZk8eXKmTp260PC7tJb1Z9KSmqPmln7fHwy+jzzyyGLXC/igeSuRA9DyhG4A2oQPXib8yiuvlDrW1KlTF3t58wfPIH/wcty//OUvjffB/v3vf8+wYcMW2n9xZ0Sbw/bbb994T+/NN9/cuNjXspj3/l5//fXMmTNnsWe7513SXKlUmvXqiGXVlDP989p88HP8YO1LOsacOXPy+uuvL3CM5vLBS8FXXXVVYRqgDXJPNwBtwpAhQxrPOt5zzz2ljrW4Vag/uL9bt25Za621Grc/8cQTSd4PX4sK3Mn89+mW4cADD2z8/pJLLmlchXtZzLtCYvbs2Rk/fvxi244ZMyZJ8vGPf3yhq9Mvby+++GJjIF6YV199NRMnTkyS+a4E6dKlS+OCfg899NBix/j3v//duFp8NVeTLMqQIUMav//Xv/7V7MdfWi195h+gLRK6AWgTevXqla233jpJ8uc//zmTJ08ubazf//73i7zse9KkSRk1alSSZOjQofNdej9nzpwkyXvvvbfIoDtz5sz8/ve/b+aK57fBBhvk85//fJL3F6A766yzmtz3vvvum29F9Q/+8eA3v/nNIvs98MADjY/vWtwfHJanoijyu9/9bpH7r7rqqsbP+cM1z3v9xBNPNP4xYWF+/etfL9CnOQ0bNqzxPuyf/exnpdyOsDS6du2a5P3F5gBoGqEbgDbjxBNPTPJ+cN17770bH6W0MP/73/+WeZzx48fn/PPPX2D7nDlzcuihhzauzH3EEUfMt3/e2dGZM2fmz3/+8wL9586dm2984xul/sFgnssvv7zxXu4f/OAHOfXUUxe5onjy/krnZ5xxRnbcccf5fq6bb755Nt100yTJr371q9x+++0L9J0+fXoOP/zwJO8vxPXhn0tLOvPMM/P0008vsP0///lPfvzjHyd5f2G+L3zhC/PtP+KIIxoXFTvssMNSV1e3wDFGjRqVK6+8Msn7P6clrXq/LHr27JkjjzwySXL//ffnmGOOWeyVC1OnTp3vDwHNbd4ihs8//3xpYwC0N+7pBqDN2GOPPXLIIYfkyiuvzP3335911103Rx55ZLbZZpv06NEjr732WsaOHZtrrrkmn/70p3PVVVct0zibbrppTjzxxIwfPz5f//rX06dPnzz77LO58MILG8967rHHHvnc5z43X7+vfOUrOeWUUzJr1qwcdNBBGT9+fHbeeefU1tbmiSeeyCWXXJJx48Zlm222Kf1S4X79+uWmm27K5z73uUydOjVnnnlmfv/732fffffNNttskz59+mT27NmZNGlS7rjjjvz1r3/Nq6++utBj/epXv8oWW2yR2bNnZ7fddstRRx2VPfbYIyuuuGL+/e9/55xzzml8dNrxxx9fymXWy2LttdfOq6++mi233DInnnhihg4dmuT956ufc845jX9cuOSSSxa4HH6DDTbIcccdl/PPPz+PPvpoNt5445x44okZMmRIZsyYkRtvvDE/+9nPMnfu3HTu3DmXX355ae/jhz/8Ye6+++489NBDufjii3PXXXfl0EMPzUYbbZQVV1wxb775Zp544oncdtttueWWW7LBBhvkG9/4Rim1bL311rnzzjvz8MMP55xzzslnP/vZxufBr7DCClljjTWW6njPPfdc7rvvvvm2vfPOO43/++F/w7vuumvjKvkAbUYBQLt05513FkmKJMVpp51WVf8777xzie0POOCAIkkxcODAhe5vai3bb799kaTYfvvtF7p/zpw5xZFHHllUKpXGYy7s64ADDliqGkaOHNm4/5FHHimGDBmyyGNvs802RV1d3ULr+81vflN06NBhkX332Wef4rbbblvsz3ZJP8ulMXHixGL33Xdf7M9q3teKK65YnH766cV77723wHFuvfXWokePHovtP2LEiGLu3LkLrWPgwIGL/Fw+qCnz5MUXX2xsN3LkyAX2f3AO3XTTTUW3bt0WWm+HDh2KCy64YJHjzJ07t/jWt7612PdcW1tb3HrrrQvtvzT/hpb0vuvq6oovfelLTfocd9hhhwX6N3VOffDfwYsvvrjA/v/9739Fr169Fjruov7NNnW8pnw15XcRQGvj8nIA2pSOHTvmkksuydixY3PYYYflE5/4RFZcccXU1NSkX79+GT58eC688MJccMEFyzzGyiuvnPvvvz9nn312Ntpoo3Tv3j0rrbRSNttss1xyySW5++67071794X2Peigg3Lvvfdmzz33zKqrrpqampqsttpq2XXXXXPNNdfk6quvXq6PYBs4cGBuuummjBkzJieeeGI233zzrLbaauncuXNWWmmlrLXWWvnyl7+cK664IpMnT85pp52WLl26LHCc4cOH57nnnsspp5ySjTbaKD169EiXLl0yYMCA7Lfffrn33nvz85//fL7nPLcGu+++e8aOHZuDDjooAwcOTOfOndOnT5/stddeue+++3Lcccctsm+HDh1y6aWX5p577sl+++2XAQMGpEuXLunRo0c22mijnHLKKXn22WczfPjw0t9H9+7d89e//jX33ntvvvGNb+STn/xkunfvnk6dOqVXr17ZbLPNMmLEiPzjH//I6NGjS6tjjTXWyJgxY3LIIYdk7bXXbrzHG4BFqxRFC6/IAQCtwFVXXZWDDjooyfurXg8aNKhlC2KZDR06NHfffXe233773HXXXS1dDgAfca3rz9EAAADQjgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidXLAQAAoCTOdAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEn+f6NM8IP44DwSAAAAAElFTkSuQmCC", "text/plain": [ "
" ] @@ -132,7 +132,7 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA94AAAMWCAYAAAAH1l7yAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAABllklEQVR4nO3deZxWZf0//tcNDCAKg4iAC5tZmlvivqSiIppmWWZ+08wtNUMtt1wqlyz3TDMtLcPyU2m2uGaCu+aCYKho7qIFAq6MgsLAnN8fPpifyDZwz2GY4fl8PObR3Odc17ne99wXk68551ynUhRFEQAAAKAU7Vq6AAAAAGjLBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAAStShpQtoKxoaGjJx4sR07do1lUqlpcsBAACgREVR5N13383qq6+edu0Wfk5b8G4mEydOTN++fVu6DAAAAJai//73v1lzzTUX2kbwbiZdu3ZN8uEPvVu3bi1cDUtbfX19RowYkaFDh6ampqaly6GNMK8og3lFGcwrymBeUYbmnFd1dXXp27dvYxZcGMG7mcy5vLxbt26C93Kovr4+Xbp0Sbdu3fwfA83GvKIM5hVlMK8og3lFGcqYV0251djiagAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFCiDi1dACxPpk6vz7iJUzP1/frUrlCTDVavTW2XmpYuCwAAKJHgDUvJUxOn5o6np6ShKBq3jR7/doas1yvrr17bgpUBAABlcqk5LAVTp9fPE7qTpKEocsfTUzJ1en0LVQYAAJRN8IalYNzEqfOE7jkaiiLjJk5dyhUBAABLi+ANS8HU9xd+RrtuEfsBAIDWS/CGpaB2hYUvoNZtEfsBAIDWS/CGpWCD1WvTrlKZ7752lUo2sLgaAAC0WYI3LAW1XWoyZL1e84TvdpVKdlmvt0eKAQBAG+ZxYrCUrL96bdbs3iXjJk5N3fv16eY53gAAsFwQvGEpqu1Sk23X7tnSZQAAAEuRS80BAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEy13wPvfcc1OpVPLd7363cdsHH3yQYcOGZZVVVslKK62UvffeO5MnT265IgEAAGgzlqvg/eijj+aKK67IRhttNNf2Y489NjfffHOuv/763HvvvZk4cWK+/OUvt1CVAAAAtCXLTfB+7733sv/+++fXv/51Vl555cbtU6dOzVVXXZWLLrooO+20UzbddNMMHz48Dz74YB5++OEWrBgAAIC2YLkJ3sOGDcsee+yRIUOGzLV9zJgxqa+vn2v7uuuum379+uWhhx5a2mUCAADQxnRo6QKWhmuvvTaPPfZYHn300Xn2TZo0KR07dkz37t3n2t67d+9MmjRpgcecMWNGZsyY0fi6rq4uSVJfX5/6+vrmKZxWY85n7rOnOZlXlMG8ogzmFWUwryhDc86rxTlGmw/e//3vf/Od73wnI0eOTOfOnZvtuOecc07OPPPMebaPGDEiXbp0abZxaF1GjhzZ0iXQBplXlMG8ogzmFWUwryhDc8yr6dOnN7ltpSiKouoRl2E33HBDvvSlL6V9+/aN22bPnp1KpZJ27drl9ttvz5AhQ/L222/Pdda7f//++e53v5tjjz12vsed3xnvvn375o033ki3bt1Kez8sm+rr6zNy5MjssssuqampaelyaCPMK8pgXlEG84oymFeUoTnnVV1dXXr27JmpU6cuMgO2+TPeO++8c5588sm5th188MFZd911c9JJJ6Vv376pqanJnXfemb333jtJ8uyzz+bVV1/N1ltvvcDjdurUKZ06dZpne01NjV8MyzGfP2UwryiDeUUZzCvKYF5RhuaYV4vTv80H765du2aDDTaYa9uKK66YVVZZpXH7oYcemuOOOy49evRIt27dcvTRR2frrbfOVltt1RIlAwAA0Ia0+eDdFD/72c/Srl277L333pkxY0Z23XXXXH755S1dFgAAAG3Achm877nnnrled+7cOZdddlkuu+yylikIAACANmu5eY43AAAAtATBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoEQdWroAyjd1en3GTZyaqe/Xp3aFmmywem1qu9S0dFkAAADLBcG7jXtq4tTc8fSUNBRF47bR49/OkPV6Zf3Va1uwMgAAgOWDS83bsKnT6+cJ3UnSUBS54+kpmTq9voUqAwAAWH4I3m3YuIlT5wndczQURcZNnLqUKwIAAFj+CN5t2NT3F35Gu24R+wEAAKie4N2G1a6w8AXUui1iPwAAANUTvNuwDVavTbtKZb772lUq2cDiagAAAKUTvNuw2i41GbJer3nCd7tKJbus19sjxQAAAJYCjxNr49ZfvTZrdu+ScROnpu79+nTzHG8AAIClSvBeDtR2qcm2a/ds6TIAAACWSy41BwAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJSozQfvc845J5tvvnm6du2aXr16Za+99sqzzz47V5sPPvggw4YNyyqrrJKVVlope++9dyZPntxCFQMAANCWtPngfe+992bYsGF5+OGHM3LkyNTX12fo0KGZNm1aY5tjjz02N998c66//vrce++9mThxYr785S+3YNUAAAC0FR1auoCy/fOf/5zr9dVXX51evXplzJgx2X777TN16tRcddVV+eMf/5iddtopSTJ8+PB8+tOfzsMPP5ytttqqJcoGAACgjWjzwfvjpk6dmiTp0aNHkmTMmDGpr6/PkCFDGtusu+666devXx566KEFBu8ZM2ZkxowZja/r6uqSJPX19amvry+rfJZRcz5znz3NybyiDOYVZTCvKIN5RRmac14tzjGWq+Dd0NCQ7373u9l2222zwQYbJEkmTZqUjh07pnv37nO17d27dyZNmrTAY51zzjk588wz59k+YsSIdOnSpVnrpvUYOXJkS5dAG2ReUQbzijKYV5TBvKIMzTGvpk+f3uS2y1XwHjZsWMaNG5cHHnig6mOdcsopOe644xpf19XVpW/fvhk6dGi6detW9fFpXerr6zNy5MjssssuqampaelyaCPMK8pgXlEG84oymFeUoTnn1ZyrnptiuQneRx11VG655Zbcd999WXPNNRu39+nTJzNnzsw777wz11nvyZMnp0+fPgs8XqdOndKpU6d5ttfU1PjFsBzz+VMG84oymFeUwbyiDOYVZWiOebU4/dv8quZFUeSoo47K3//+99x1110ZOHDgXPs33XTT1NTU5M4772zc9uyzz+bVV1/N1ltvvbTLBQAAoI1p82e8hw0blj/+8Y+58cYb07Vr18b7tmtra7PCCiuktrY2hx56aI477rj06NEj3bp1y9FHH52tt97aiuYAAABUrc0H71/+8pdJksGDB8+1ffjw4TnooIOSJD/72c/Srl277L333pkxY0Z23XXXXH755Uu5UgAAANqiNh+8i6JYZJvOnTvnsssuy2WXXbYUKgIAAGB50ubv8QYAAICWJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEHZb2gLNnz86ECROSJP369VvawwMAAMBStdhnvKdPn54f//jH2XjjjdO1a9d07949W2+9dS6++OJ88MEHi+z/zDPPZMCAAVlrrbWWqGAAAABoTRbrjPd///vf7LLLLnn++eeTJEVRJElGjRqVUaNG5eKLL87vfve77LDDDos81py+AAAA0JY1+Yz37Nmz85WvfCXPPfdciqJITU1NNttss2y44YZp3759iqLIq6++mp133jk//elPy6wZAAAAWo0mB++//OUvefTRR1OpVPLVr341EydOzKhRo/L4449nwoQJOf7449OhQ4c0NDTke9/7Xk4++eQy6wYAAIBWocnB+9prr02SbL755vnTn/6UHj16NO5bddVVc8EFF+T+++/PmmuumaIocsEFF+Rb3/pW81cMAAAArUiTg/fo0aNTqVRyzDHHpFKpzLfNlltumUcffTSbbLJJiqLIr3/96+y///5paGhotoIBAACgNWly8H7jjTeSJJ/+9KcX2q5379655557Mnjw4BRFkWuvvTZ777136uvrq6sUAAAAWqEmB+927T5s2pRHhq200kq57bbb8vnPfz5FUeSmm27K5z//+bz//vtLXikAAAC0Qk0O3mussUaSND5KbFE6deqUv//979l3331TFEXuuOOO7Lrrrqmrq1uySgEAAKAVanLw3mijjZIkd955Z5MP3r59+/zxj3/MoYcemqIo8q9//Sv77rvv4lcJAAAArVSTg/ece7b//ve/Z9q0aU0eoFKp5Ne//nW++93vpiiKTJgwYYkKBQAAgNaoycF7zz33TKVSybRp0/LLX/5ysQe66KKLctppp6UoisXuCwAAAK1Vh6Y27N+/f77//e/ntddey5tvvrlEg51xxhlZZZVV8re//W2J+gMAAEBr0+TgnSQ/+tGPqh7w6KOPztFHH131cQAAAKA1aPKl5gAAAMDiE7wBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEVQXvnXbaKTvvvHNeeeWVJveZOHFiYz8AAABo6zpU0/mee+5JpVLJtGnTmtzn/fffb+wHAAAAbZ1LzQEAAKBESz14zzk73rlz56U9NAAAACx1Sz1433bbbUmSNddcc2kPDQAAAEvdYt3jfcghh8x3+w9+8IN07959oX1nzJiRF198MY8++mgqlUp22GGHxRkaAAAAWqXFCt5XX331PIuiFUWRG2+8sUn9i6JIkvTo0SOnnHLK4gwNAAAArdJiBe9+/frNFbxfeeWVVCqVrLbaaqmpqVlgv0qlks6dO2e11VbLNttskyOPPDKrr776klcNAAAArcRiBe/x48fP9bpduw9vER8xYkTWW2+9ZisKAAAA2oqqnuO9/fbbp1KpZMUVV2yuegAAAKBNqSp433PPPc1UBgAAALRNS/1xYgAAALA8qeqM9/zU1dXl3XffzezZsxfZtl+/fs09PAAAACxTmiV4jxw5MpdffnkeeOCBvPXWW03qU6lUMmvWrOYYHgAAAJZZVQfvY445JpdddlmS//853QAAAMCHqgref/zjH/OLX/wiSdK5c+fstdde2XTTTdOjR4/GR40BAADA8qyq4H3FFVckSfr27Zu77rorn/jEJ5qlKAAAAGgrqjot/cQTT6RSqeT0008XugEAAGA+qgre9fX1SZJBgwY1SzEAAADQ1lQVvAcMGJAkee+995qjFgAAAGhzqgreX/7yl5Mkd955Z7MUAwAAAG1NVcH7+OOPT79+/XLxxRfnmWeeaa6aAAAAoM2oKnjX1tbm9ttvT+/evbPNNtvk8ssvz9tvv91ctQEAAECrV9XjxNZaa60kyfTp0/POO+/k6KOPzjHHHJOePXumS5cuC+1bqVTy4osvVjM8AAAALPOqCt7jx4+f63VRFCmKIlOmTFlk30qlUs3QAAAA0CpUFbwPPPDA5qoDAAAA2qSqgvfw4cObqw4AAABok6paXA0AAABYOMEbAAAASlTVpeYf9/7772fMmDGZNGlSpk+fnr322ivdunVrziEAAACgVWmW4P3f//43p556aq6//vrU19c3bt9ss82y3nrrNb6+6qqrcsUVV6S2tjYjRoywsjkAAABtXtWXmj/yyCMZNGhQ/vjHP2bmzJmNjxSbnz333DNPPPFE7rrrrowYMaLaoQEAAGCZV1Xwfuedd/LFL34xb731Vvr06ZPLL788Tz755ALb9+rVK5/73OeSJLfeems1QwMAAECrUNWl5j//+c8zZcqU9OzZMw899FD69eu3yD5DhgzJjTfemFGjRlUzNAAAALQKVZ3xvvnmm1OpVHLcccc1KXQnyfrrr58kefHFF6sZGgAAAFqFqoL3Cy+8kCTZfvvtm9xn5ZVXTpLU1dVVMzQAAAC0ClUF7w8++CBJUlNT0+Q+06ZNS5KssMIK1QwNAAAArUJVwbtXr15JkpdffrnJfcaOHZskWX311asZGgAAAFqFqoL3lltumSS57bbbmtS+KIr8+te/TqVSyXbbbVfN0AAAANAqVBW8999//xRFkT/84Q+NZ7IX5vjjj8/jjz+eJDnwwAOrGRoAAABahaqC9xe/+MXsuOOOmTVrVnbeeef88pe/zJQpUxr3z5o1KxMnTsz111+f7bbbLpdcckkqlUq+/OUvZ5tttqm6eAAAAFjWVfUc7yT561//mp133jn//ve/c9RRR+Woo45KpVJJkgwaNGiutkVRZKuttsrVV19d7bAAAADQKlR1xjtJunfvnoceeiinnHJKunXrlqIo5vu1wgor5Hvf+17uueeerLjiis1ROwAAACzzqj7jnSQdO3bMT37yk5x66qm59957M3r06EyZMiWzZ8/OKquskkGDBmXIkCGpra1tjuEAAACg1WiW4D3HiiuumN133z277757cx4WAAAAWq2qLzUHAAAAFkzwBgAAgBI126Xmb775Zh566KG89NJLeffddzN79uxF9jnttNOaa3gAAABYJlUdvKdMmZJjjz02f/nLXzJr1qzF6it4AwAA0NZVdan522+/nc9+9rO59tprU19fv8BHiS3oa1lz2WWXZcCAAencuXO23HLLjBo1qqVLAgAAoJWrKnife+65eeGFF1IURYYOHZp//vOfef311zN79uw0NDQs8mtZct111+W4447L6aefnsceeyyf+cxnsuuuu2bKlCktXRoAAACtWFXB+8Ybb0ylUsnnP//5/POf/8zQoUOzyiqrpFKpNFd9S81FF12Uww47LAcffHDWW2+9/OpXv0qXLl3y29/+tqVLAwAAoBWr6h7vV199NUkybNiwZimmpcycOTNjxozJKaec0ritXbt2GTJkSB566KH59pkxY0ZmzJjR+Lquri5JUl9fn/r6+nILZpkz5zP32dOczCvKYF5RBvOKMphXlKE559XiHKOq4L3SSitlxowZ6d27dzWHaXFvvPFGZs+ePc/76N27d5555pn59jnnnHNy5plnzrN9xIgR6dKlSyl1suwbOXJkS5dAG2ReUQbzijKYV5TBvKIMzTGvpk+f3uS2VQXvDTfcMPfcc09eeeWVbLzxxtUcqtU55ZRTctxxxzW+rqurS9++fTN06NB069atBSujJdTX12fkyJHZZZddUlNT09Ll0EaYV5TBvKIM5hVlMK8oQ3POqzlXPTdFVcH7iCOOyN13351rrrkmX/ziF6s5VIvq2bNn2rdvn8mTJ8+1ffLkyenTp898+3Tq1CmdOnWaZ3tNTY1fDMsxnz9lMK8og3lFGcwrymBeUYbmmFeL07+qxdW++tWvZv/998/f//73nHvuudUcqkV17Ngxm266ae68887GbQ0NDbnzzjuz9dZbt2BlAAAAtHZVnfG+7777cuihh+bll1/O97///fztb3/Lfvvtl3XXXbdJ9zlvv/321QzfrI477rgceOCB2WyzzbLFFlvk4osvzrRp03LwwQe3dGkAAAC0YlUF78GDB8/16LAxY8ZkzJgxTepbqVQya9asaoZvVvvuu29ef/31nHbaaZk0aVI23njj/POf/2z1C8cBAADQsqoK3klSFEVz1LFMOOqoo3LUUUe1dBkAAAC0IVUF77vvvru56gAAAIA2qargvcMOOzRXHQAAAJCp0+szbuLUTH2/PrUr1GSD1WtT26V1r2xf9aXmAAAA0Byemjg1dzw9JQ0fuaV59Pi3M2S9Xll/9doWrKw6VT1ODAAAAJrD1On184TuJGkoitzx9JRMnV7fQpVVr1nPeI8ZMyZ33HFHxo0bl7feeitJ0qNHj2ywwQYZMmRINt100+YcDgAAgDZi3MSp84TuORqKIuMmTs22a/dcylU1j2YJ3k8++WQOP/zwjBo1aoFtTj311Gy55Za54oorsuGGGzbHsAAAALQRU99f+BntukXsX5ZVfan5HXfckS222CKjRo1KURQpiiIdOnRI796907t373To0KFx+8MPP5wtttgid955Z3PUDgAAQBtRu8LCF1Drtoj9y7Kqgvcbb7yRffbZJzNmzEilUsk3v/nNPPLII5k2bVomTpyYiRMnZvr06Rk1alQOO+ywtG/fPjNmzMg+++yTN998s7neAwAAAK3cBqvXpl2lMt997SqVbLC8Lq52ySWXZOrUqenYsWNuvfXWXHnlldl8883TocP/fwV7+/bts9lmm+WKK67IrbfempqamkydOjWXXHJJ1cUDAADQNtR2qcmQ9XrNE77bVSrZZb3erfqRYlUF71tvvTWVSiVHHXVUdt1110W2Hzp0aI4++ugURZFbb721mqEBAABoY9ZfvTYHbTMgWwzskXX7dM0WA3vkoG0GZL3Vu7V0aVWpKni//PLLSZIvfOELTe4zp+1LL71UzdAAAAC0QbVdarLt2j3zuQ1Xy7Zr92zVZ7rnqCp4f/DBB0mSFVdcscl95rSdMWNGNUMDAABAq1BV8O7Tp0+S5N///neT+8xp27t372qGBgAAgFahquC93XbbpSiKnHvuuamrq1tk+3fffTfnnXdeKpVKtttuu2qGBgAAgFahquB9xBFHJPnwXu/tt98+o0ePXmDb0aNHZ4cddsiLL744V18AAABoyzosusmCbbvttvn2t7+dyy+/PE8++WS23HLLrL/++tlyyy3Tq1evVCqVTJ48OY888kieeuqpxn7f/va3s+2221ZdPAAAACzrqgreSXLppZemS5cuueiii9LQ0JBx48bNFbKTpCiKJEm7du1ywgkn5Nxzz612WAAAAGgVqrrUPEkqlUrOP//8jB07NkceeWQ++clPpiiKub4++clP5sgjj8zYsWMb7/EGAACA5UHVZ7zn2GCDDXLZZZclSWbOnJm33347SbLyyiunY8eOzTUMAAAAtCrNFrw/qmPHjh4XBgAAAGmGS80BAACABWu2M96zZ8/OjTfemDvuuCNPPvlk3nrrrSRJjx49ssEGG2TIkCH54he/mA4dSjnJDgAAAMukZknBN910U4466qhMmDChcduclcwrlUoefPDBXHnllVlttdXyi1/8InvttVdzDAsAAADLvKovNb/kkkvypS99KRMmTGgM2wMGDMhWW22VrbbaKgMGDEjyYRCfOHFi9t5771x88cXVDgsAAACtQlXB+5FHHsnxxx+foijStWvXnHfeeZk8eXJefPHFPPjgg3nwwQfz4osvZvLkyTnvvPNSW1uboihy4okn5pFHHmmu9wAAAADLrKqC90UXXZSGhobU1tbmwQcfzIknnpiePXvO065nz5458cQT8+CDD6a2tjYNDQ256KKLqhkaAAAAWoWqgvf999+fSqWSk046Keutt94i23/605/OSSedlKIoct9991UzNAAAALQKVQXvt99+O0my4447NrnPnLbvvPNONUMDAABAq1BV8F5ttdVapC8AAAC0FlUF7yFDhiRJ7r333ib3ueeee5IkO+20UzVDAwAAQKtQVfA+/vjjs8IKK+Tcc8/Nc889t8j2zz33XM4777ysuOKKOfHEE6sZGgAAAFqFqoL3Ouusk7/85S9Jkq222ioXX3xx3nrrrXnavf3227nkkkuyzTbbJEn+/Oc/Z5111qlmaAAAAGgVOlTTec7l4quuumqef/75HH/88TnhhBMycODA9OrVK5VKJZMnT87LL7+coiiSJGuvvXYuuOCCXHDBBfM9ZqVSyZ133llNWQAAALDMqCp433PPPalUKo2vi6JIURR58cUX8+KLL863zwsvvJAXXnihMYjPUalUUhTFXMcDAACA1q6q4L399tsLygAAALAQVZ/xBgAAABasqsXVAAAAgIUTvAEAAKBEgjcAAACUqKp7vD+qoaEhTz/9dF566aW8++67mT179iL7fOMb32iu4QEAAGCZVHXwnj59en784x/nN7/5Td58880m96tUKoI3AAAAbV5Vwfu9997LjjvumMcee2ye53IDAAAAVQbvH//4xxkzZkySZKuttsrhhx+ez3zmM+nevXvatXP7OAAAAFQVvP/yl7+kUqlk9913z4033ihsAwAAwMdUlZQnTJiQJDnmmGOEbgAAAJiPqtJyr169kiQ9e/ZslmIAAACgrakqeG+xxRZJkmeffbZZigEAAIC2pqrgfeyxxyZJfvGLX1jVHAAAAOajquC9zTbb5LzzzsuDDz6Y//f//l/eeeedZioLAAAA2oaqVjVPkhNOOCGf+MQncthhh6Vv377ZZZdd8qlPfSpdunRZZN/TTjut2uEBAABgmVZ18J4yZUr+/ve/Z+rUqWloaMiNN97Y5L6CNwAAAG1dVcH7zTffzPbbb5/nn3/ePd4AAAAwH1Xd43322WfnueeeS1EU+cpXvpK77rorb775ZmbPnp2GhoZFfgEAAEBbV9UZ75tuuimVSiVf//rX87vf/a65agIAAIA2o6oz3hMmTEiSHHLIIc1SDAAAALQ1VQXvnj17Jkm6du3aLMUAAABAW1NV8N5uu+2SJOPGjWuWYgAAAKCtqSp4H3/88ampqcmFF16YDz74oLlqAgAAgDajquC9ySab5De/+U2ee+65DB06NM8991xz1QUAAABtQlWrms9ZVG299dbLAw88kPXWWy8bbbRRPvWpT6VLly4L7VupVHLVVVdVMzwAAAAs86oK3ldffXUqlUqSD4N0Q0NDHn/88Tz++OML7VcUheANAADAcqGq4N2vX7/G4A0AAADMq6rgPX78+GYqAwAAANqmqhZXAwAAABZO8AYAAIASVXWp+YLMmjUrb7/9dpJk5ZVXTocOpQwDAAAAy7xmO+P9n//8J0cffXQ+/elPp3PnzunTp0/69OmTzp0759Of/nSOOeaYPP300801HAAAALQKzRK8TznllGy00Ua5/PLL8+yzz6ahoSFFUaQoijQ0NOTZZ5/NZZddls985jM59dRTm2NIAAAAaBWqvgb86KOPzuWXX56iKJIkn/70p7PlllumT58+SZJJkyZl1KhRefrppzN79uycd955mTZtWi655JJqhwYAAIBlXlXB+1//+lcuu+yyVCqVrLfeernyyiuzzTbbzLftQw89lG9961t58skn84tf/CL77rvvAtsCAABAW1HVpeZXXHFFkmTgwIH517/+tdAgvfXWW+e+++7LWmutlST51a9+Vc3QAAAA0CpUFbzvv//+VCqVnHzyyamtrV1k+9ra2px00kkpiiL3339/NUMDAABAq1BV8J40aVKSZNCgQU3us8kmmyRJJk+eXM3QAAAA0CpUFbw7d+6cJJk2bVqT+8xp26lTp2qGBgAAgFahquA9cODAJMnNN9/c5D5z2s651xsAAADasqqC9+67756iKHLppZfmzjvvXGT7u+++O5deemkqlUp23333aoYGAACAVqGq4P3d73433bp1S319fT73uc/lqKOOymOPPZaGhobGNg0NDXnsscdy1FFHZbfddsvMmTPTrVu3fPe73622dgAAAFjmVfUc7549e+bPf/5zvvCFL2TmzJn55S9/mV/+8pfp2LFjevTokUqlkjfffDMzZ85MkhRFkY4dO+b666/PKqus0ixvAAAAAJZlVZ3xTpKhQ4fm4YcfzmabbZaiKFIURWbMmJHXXnstEydOzIwZMxq3b7bZZnnkkUcyZMiQ5qgdAAAAlnlVnfGeY+ONN86oUaPy6KOP5o477si4cePy1ltvJUl69OiRDTbYIEOGDMnmm2/eHMMBAABAq9EswXuOzTffXLgGAACAj6j6UnMAAABgwRbrjPeMGTPy7LPPJklqa2vTv3//Jvd95ZVXMnXq1CTJpz/96dTU1CzO0AAAANAqLdYZ77POOiuDBg3KFltskf/973+LNdD//ve/bL755hk0aFDOO++8xeoLAAAArVWTg/fUqVPzs5/9LElyyimnZNttt12sgbbddtuceuqpKYoi559/ft57773FqxQAAABaoSYH7+uuuy7vv/9+VllllZxwwglLNNiJJ56YVVddNdOmTct11123RMcAAACA1qTJwXvkyJGpVCr50pe+lBVXXHGJBuvSpUv23nvvFEWRESNGLNExAAAAoDVpcvD+97//nSQZMmRIVQPutNNOSZLHHnusquMAAABAa9Dk4P36668nSdZcc82qBlxjjTWSJFOmTKnqOAAAANAaNDl4z5gxI0nSsWPHqgac03/mzJlVHQcAAABagyYH7549eyZJJk+eXNWAc850r7LKKlUdBwAAAFqDJgfvvn37JkkefPDBqgb817/+NdfxAAAAoC1rcvDecccdUxRF/vSnP2XWrFlLNFh9fX3++Mc/plKpZMcdd1yiYwAAAEBr0uTgvffeeydJxo8fnx//+MdLNNhPfvKTjB8/fq7jAQAAQFvW5OC96aabZo899khRFDnrrLNyzjnnpCiKJg909tln50c/+lEqlUr22GOPbLrppktUMAAAALQmTQ7eSXLZZZeld+/eSZIf/OAH2WyzzfK73/2u8VFjH/f666/n6quvzqabbpof/vCHSZLevXvnsssuq7JsAAAAaB06LE7jfv365eabb86ee+6ZyZMnZ+zYsTnkkEOSfPh87l69emXFFVfMtGnTMnny5EycOLGxb1EU6d27d26++WYLqwEAALDcWKzgnSSbbbZZxo4dm8MPPzw333xz4/YJEyZkwoQJc7X96KXoX/jCF3LFFVc0njEHAACA5cFiB+/kw8vFb7zxxjz11FMZPnx47r333jzxxBOpr69vbFNTU5ONNtooO+ywQw466KBssMEGzVY0AAAAtBZLFLznWH/99XPhhRc2vn733Xfz7rvvpmvXrunatWvVxQEAAEBrV1Xw/jiBGwAAAOa2WKuaAwAAAItH8AYAAIAStengPX78+Bx66KEZOHBgVlhhhXziE5/I6aefnpkzZ87V7oknnsh2222Xzp07p2/fvjn//PNbqGIAAADamma9x3tZ88wzz6ShoSFXXHFF1l577YwbNy6HHXZYpk2b1rgoXF1dXYYOHZohQ4bkV7/6VZ588skccsgh6d69ew4//PAWfgcAAAC0dm06eO+2227ZbbfdGl+vtdZaefbZZ/PLX/6yMXj/4Q9/yMyZM/Pb3/42HTt2zPrrr5+xY8fmoosuErwBAACoWpu+1Hx+pk6dmh49ejS+fuihh7L99tunY8eOjdt23XXXPPvss3n77bdbokQAAADakDZ9xvvjXnjhhVx66aVzPXt80qRJGThw4Fztevfu3bhv5ZVXnu+xZsyYkRkzZjS+rqurS5LU19envr6+uUtnGTfnM/fZ05zMK8pgXlEG84oymFeUoTnn1eIco1UG75NPPjnnnXfeQtv85z//ybrrrtv4esKECdltt92yzz775LDDDqu6hnPOOSdnnnnmPNtHjBiRLl26VH18WqeRI0e2dAm0QeYVZTCvKIN5RRnMK8rQHPNq+vTpTW5bKYqiqHrEpez111/Pm2++udA2a621VuPl4xMnTszgwYOz1VZb5eqrr067dv//Ffbf+MY3UldXlxtuuKFx2913352ddtopb7311mKd8e7bt2/eeOONdOvWrYp3R2tUX1+fkSNHZpdddklNTU1Ll0MbYV5RBvOKMphXlMG8ogzNOa/q6urSs2fPTJ06dZEZsFWe8V511VWz6qqrNqnthAkTsuOOO2bTTTfN8OHD5wrdSbL11lvn+9//furr6xt/8CNHjsw666yzwNCdJJ06dUqnTp3m2V5TU+MXw3LM508ZzCvKYF5RBvOKMphXlKE55tXi9G/Ti6tNmDAhgwcPTr9+/XLhhRfm9ddfz6RJkzJp0qTGNvvtt186duyYQw89NE899VSuu+66XHLJJTnuuONasHIAAADaiiad8V5rrbWafeBKpZIXX3yx2Y/7USNHjswLL7yQF154IWuuueZc++ZcYV9bW5sRI0Zk2LBh2XTTTdOzZ8+cdtppHiUGAABAs2hS8B4/fnyzD1ypVJr9mB930EEH5aCDDlpku4022ij3339/6fUAAACw/GlS8D7wwAPLrgMAAADapCYF7+HDh5ddBwAAALRJbXpxNQAAAGhpgjcAAACUSPAGAACAEjXpHu/FMX78+Lzxxht5//33Gx/ZtSDbb799cw8PAAAAy5RmCd7PPvtszj777Nx0002pq6trUp9KpZJZs2Y1x/AAAACwzKo6eN9www3Zf//988EHHyzyDDcAAAAsb6oK3v/973/z9a9/Pe+//37WWGONnHjiienSpUsOP/zwVCqV3HHHHXnrrbcyevToXHPNNZk4cWI++9nP5owzzkj79u2b6z0AAADAMquq4P3zn/8806dPT9euXfPII49k9dVXz1NPPdW4f8cdd0yS7L333jnttNNy6KGH5rrrrstVV12VP/zhD9VVDgAAAK1AVaua33HHHalUKvn2t7+d1VdffaFtV1hhhfzf//1fBg0alGuvvTZ//etfqxkaAAAAWoWqgvf48eOTJNtss03jtkql0vj9xxdPa9euXY455pgURZHf/va31QwNAAAArUJVwXvatGlJkr59+zZu69KlS+P3U6dOnafP+uuvnyR5/PHHqxkaAAAAWoWqgndtbW2S5IMPPmjctsoqqzR+/+KLL87TZ04Yf+ONN6oZGgAAAFqFqoL3OuuskyR56aWXGrd17do1/fv3T5KMGDFinj4jR45MknTv3r2aoQEAAKBVqCp4b7311kmShx9+eK7tn//851MURS644ILcfffdjdv//Oc/55JLLkmlUsm2225bzdAAAADQKlQVvHffffcURZG//e1vmT17duP2Oc/zfu+99zJkyJCsuuqq6dq1a772ta/lgw8+SLt27XLiiSdWXTwAAAAs66oK3oMHD87pp5+egw8+OBMmTGjc3q9fv1x//fWpra1NURR58803M23atBRFkU6dOuXXv/51ttpqq6qLBwAAgGVdh2o6VyqVnH766fPd97nPfS7PP/98/vKXv+Spp57KrFmz8slPfjJf/epXs8Yaa1QzLAAAALQaVQXvRVlllVVyxBFHlDkEAAAALNOqutQcAAAAWLhmP+NdFEVeeumlvPXWW0mSHj16ZK211kqlUmnuoQAAAGCZ12zB+5///Gcuv/zy3HPPPZk2bdpc+7p06ZLBgwfn29/+dj73uc8115AAAACwzKv6UvPp06dn7733zh577JFbb7017733XoqimOtr2rRp+cc//pHPf/7z+dKXvjRPMAcAAIC2qqoz3g0NDdl9991z//33pyiK1NTUZOjQodliiy3Su3fvJMnkyZPz6KOPZsSIEZk5c2Zuuumm7L777rnnnntcfg4AAECbV1XwvuKKK3LfffelUqlk1113zW9+85sFPipswoQJOeyww/LPf/4zDzzwQH71q1/lyCOPrGZ4AAAAWOZVdan57373uyTJ5ptvnltvvXWhz+deY401cvPNN2eLLbZIURSNfQEAAKAtqyp4/+c//0mlUsmxxx6bdu0Wfaj27dvnuOOOa+wLAAAAbV1VwXvOPdqf+tSnmtznk5/85Fx9AQAAoC2rKnh/4hOfSJJMmTKlyX3mtJ3TFwAAANqyqoL31772tRRFkd///vdN7vP73/8+lUol++67bzVDAwAAQKtQVfA+5phjsskmm+Taa6/N+eefv8j2F1xwQf70pz9l0KBB+e53v1vN0AAAANAqVPU4sUmTJuU3v/lNjjjiiJxyyin505/+lAMPPDCbb755evXqlUql0vgc72uuuSZjx47N5ptvniuvvDKTJk1a4HH79etXTVkAAACwzKgqeA8YMGCuRdKeeOKJHH/88QvtM3r06GyyySYL3F+pVDJr1qxqygIAAIBlRlXBO0mKomiOOgAAAKBNqip4Dx8+vLnqAAAAgDapquB94IEHNlcdAAAA0CZVtao5AAAAsHCCNwAAAJRI8AYAAIASNeke7x/96EeN35922mnz3b4kPnosAAAAaIuaFLzPOOOMxud1fzQsf3T7khC8AQAAaOuavKr5gp7X7TneAAAAsGBNCt4NDQ2LtR0AAAD4kMXVAAAAoESCNwAAAJRI8AYAAIASVRW8J02alEMOOSSHHHJIJkyYsMj2EyZMyCGHHJJDDz00b731VjVDAwAAQKtQVfC+5pprcvXVV2fs2LFZY401Ftl+jTXWyNixY3P11Vfn//7v/6oZGgAAAFqFqoL3iBEjUqlU8pWvfKXJffbdd98URZHbbrutmqEBAACgVagqeI8bNy5JssUWWzS5z2abbZYkeeKJJ6oZGgAAAFqFqoL3m2++mSRZddVVm9ynZ8+ec/UFAACAtqyq4L3SSislSaZOndrkPnV1dUmSjh07VjM0AAAAtApVBe8111wzSfLQQw81uc+//vWvJGnSYmwAAADQ2lUVvAcPHpyiKHLppZc2nslemLq6uvziF79IpVLJ4MGDqxkaAAAAWoWqgvcRRxyRSqWS1157LXvssUcmT568wLaTJk3KHnvskYkTJ6ZSqeSII46oZmgAAABoFTpU03n99dfPd77znVx88cV58MEHs/baa2fffffNdtttl9VWWy1J8tprr+W+++7Ln//850yfPj2VSiXDhg3Lxhtv3Bz1AwAAwDKtquCdJBdeeGGmTp2a4cOHZ9q0aRk+fHiGDx8+T7uiKJIk3/zmN3PxxRdXOywAAAC0ClVdap4k7dq1y1VXXZUbbrghW2+9dZIPQ/ZHv5Jk2223zU033ZQrr7wylUql2mEBAACgVaj6jPccX/jCF/KFL3whb731VsaOHZs33ngjyYfP7R40aFBWXnnl5hoKAAAAWo1mC95z9OjRIzvttFNzHxYAAABapaovNQcAAAAWTPAGAACAEjXLpeazZs3Krbfemvvvvz8vvfRS3n333cyePXuhfSqVSu68887mGB4AAACWWVUH7wceeCAHHHBAXn311cZtc1Yyn59KpZKiKKxsDgAAwHKhquD9zDPPZLfddsv777+foijSsWPHfPKTn0yPHj3Srp2r2AEAAKCq4H322Wdn+vTpad++fc4888wcc8wxWWmllZqrNgAAAGj1qgred911VyqVSr7zne/k1FNPba6aAAAAoM2o6nrwN954I0nypS99qVmKAQAAgLamquC96qqrJklWWGGFZikGAAAA2pqqgvdnP/vZJMm4ceOapRgAAABoa6oK3scdd1zat2+fSy65JLNmzWqumgAAAKDNqCp4b7755rn44ovz+OOP58tf/nLjPd8AAADAh6pa1fxHP/pRkmSLLbbILbfckv79+2eXXXbJuuuumy5duiyy/2mnnVbN8AAAALDMqyp4n3HGGalUKkmSSqWS999/PzfffHNuvvnmJvUXvAEAAGjrqgreSVIUxUJfAwAAwPKsquDd0NDQXHUAAABAm1TV4moAAADAwgneAAAAUCLBGwAAAEokeAMAAECJmrS42lprrZXkw0eGvfjii/NsXxIfPxYAAAC0RU0K3uPHj0+Sxmd2f3z7kvj4sQAAAKAtalLwPvDAAxdrOwAAAPChJgXv4cOHL9Z2AAAA4EMWVwMAAIASNemM94IccsghSZLPfe5z2WeffZqlIAAAAGhLqgrev/vd75Ik++67b7MUAwAAAG1NVZear7rqqkmS3r17N0sxAAAA0NZUFbzXW2+9JMkrr7zSLMUAAABAW1NV8P7617+eoigaLzkHAAAA5lZV8D744IOz884758Ybb8wZZ5yRoiiaqy4AAABoE6paXO3+++/PCSeckNdffz1nnXVWrrvuuuy7777ZaKONsvLKK6d9+/YL7b/99ttXMzwAAAAs86oK3oMHD06lUml8/dxzz+Wss85qUt9KpZJZs2ZVMzwAAAAs86oK3klcXg4AAAALUVXwvvvuu5urDgAAAGiTqgreO+ywQ3PVAQAAAG1SVauaAwAAAAu3RGe8b7311vzzn//MK6+8ktmzZ2f11VfP4MGD89WvfjU1NTXNXSMAAAC0WosVvCdPnpy99toro0aNmmffb3/725x22mm54YYbsuGGGzZbgQAAANCaNflS89mzZ+cLX/hCHnnkkRRFMd+vl19+ObvuumveeOONMmsGAACAVqPJwfvPf/5zHn300VQqlay99tq56qqr8uSTT+aZZ57J9ddfn6222irJh2fFf/rTn5ZWMAAAALQmixW8k2TAgAEZNWpUDj744Ky//vr51Kc+lb333jv3339/dthhhxRFkeuvv760ggEAAKA1aXLw/ve//51KpZLjjz8+3bt3n2d/+/btc+aZZyZJXn755bz77rvNVmRzmDFjRjbeeONUKpWMHTt2rn1PPPFEtttuu3Tu3Dl9+/bN+eef3zJFAgAA0OY0OXi//vrrSZLNNttsgW0+um9Zu8/7e9/7XlZfffV5ttfV1WXo0KHp379/xowZkwsuuCBnnHFGrrzyyhaoEgAAgLamyauav//++6lUKllppZUW2KZLly6N33/wwQfVVdaMbrvttowYMSJ//etfc9ttt8217w9/+ENmzpyZ3/72t+nYsWPWX3/9jB07NhdddFEOP/zwFqoYAACAtqLJZ7wXV1EUZR16sUyePDmHHXZYrrnmmrn+MDDHQw89lO233z4dO3Zs3Lbrrrvm2Wefzdtvv700SwUAAKANWqzneLc2RVHkoIMOyre+9a1sttlmGT9+/DxtJk2alIEDB861rXfv3o37Vl555fkee8aMGZkxY0bj67q6uiRJfX196uvrm+kd0FrM+cx99jQn84oymFeUwbyiDOYVZWjOebU4x1js4H355ZenV69ezdLutNNOW9zhkyQnn3xyzjvvvIW2+c9//pMRI0bk3XffzSmnnLJE4yzMOeec07iY3EeNGDFivmfWWT6MHDmypUugDTKvKIN5RRnMK8pgXlGG5phX06dPb3LbStHEa8LbtWuXSqWyxEXNz+zZs5eo3+uvv54333xzoW3WWmutfPWrX83NN988V92zZ89O+/bts//+++d3v/tdvvGNb6Suri433HBDY5u77747O+20U956663FOuPdt2/fvPHGG+nWrdsSvS9ar/r6+owcOTK77LJLampqWroc2gjzijKYV5TBvKIM5hVlaM55VVdXl549e2bq1KmLzICLdca7Oe/bribEr7rqqll11VUX2e7nP/95fvzjHze+njhxYnbddddcd9112XLLLZMkW2+9db7//e+nvr6+8Qc/cuTIrLPOOgsM3UnSqVOndOrUaZ7tNTU1fjEsx3z+lMG8ogzmFWUwryiDeUUZmmNeLU7/Jgfvu+++e4mKaUn9+vWb6/WcFdk/8YlPZM0110yS7LfffjnzzDNz6KGH5qSTTsq4ceNyySWX5Gc/+9lSrxcAAIC2p8nBe4cddiizjhZTW1ubESNGZNiwYdl0003Ts2fPnHbaaR4lBgAAQLNo06uaf9yAAQPme7n8RhttlPvvv78FKgIAAKCtK+053gAAAIDgDQAAAKUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJlovgfeutt2bLLbfMCiuskJVXXjl77bXXXPtfffXV7LHHHunSpUt69eqVE088MbNmzWqZYgEAAGhTOrR0AWX761//msMOOyxnn312dtppp8yaNSvjxo1r3D979uzsscce6dOnTx588MG89tpr+cY3vpGampqcffbZLVg5AAAAbUGbDt6zZs3Kd77znVxwwQU59NBDG7evt956jd+PGDEiTz/9dO6444707t07G2+8cc4666ycdNJJOeOMM9KxY8eWKB0AAIA2ok0H78ceeywTJkxIu3btMmjQoEyaNCkbb7xxLrjggmywwQZJkoceeigbbrhhevfu3dhv1113zZFHHpmnnnoqgwYNmu+xZ8yYkRkzZjS+rqurS5LU19envr6+xHfFsmjOZ+6zpzmZV5TBvKIM5hVlMK8oQ3POq8U5RpsO3i+99FKS5IwzzshFF12UAQMG5Kc//WkGDx6c5557Lj169MikSZPmCt1JGl9PmjRpgcc+55xzcuaZZ86zfcSIEenSpUszvgtak5EjR7Z0CbRB5hVlMK8og3lFGcwrytAc82r69OlNbtsqg/fJJ5+c8847b6Ft/vOf/6ShoSFJ8v3vfz977713kmT48OFZc801c/311+eII45Y4hpOOeWUHHfccY2v6+rq0rdv3wwdOjTdunVb4uPSOtXX12fkyJHZZZddUlNT09Ll0EaYV5TBvKIM5hVlMK8oQ3POqzlXPTdFqwzexx9/fA466KCFtllrrbXy2muvJZn7nu5OnTplrbXWyquvvpok6dOnT0aNGjVX38mTJzfuW5BOnTqlU6dO82yvqanxi2E55vOnDOYVZTCvKIN5RRnMK8rQHPNqcfq3yuC96qqrZtVVV11ku0033TSdOnXKs88+m89+9rNJPvwLx/jx49O/f/8kydZbb52f/OQnmTJlSnr16pXkw8sOunXrNldgBwAAgCXRKoN3U3Xr1i3f+ta3cvrpp6dv377p379/LrjggiTJPvvskyQZOnRo1ltvvRxwwAE5//zzM2nSpPzgBz/IsGHD5ntGGwAAABZHmw7eSXLBBRekQ4cOOeCAA/L+++9nyy23zF133ZWVV145SdK+ffvccsstOfLII7P11ltnxRVXzIEHHpgf/ehHLVw5AAAAbUGbD941NTW58MILc+GFFy6wTf/+/fOPf/xjKVYFAADA8qJdSxcAAAAAbZngDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFCiDi1dAMunqdPrM27i1Ex9vz61K9Rkg9VrU9ulpqXLAgAAaHaCN0vdUxOn5o6np6ShKBq3jR7/doas1yvrr17bgpUBAAA0P5eas1RNnV4/T+hOkoaiyB1PT8nU6fUtVBkAAEA5BG+WqnETp84TuudoKIqMmzh1KVcEAABQLsGbpWrq+ws/o123iP0AAACtjeDNUlW7wsIXUOu2iP0AAACtjeDNUrXB6rVpV6nMd1+7SiUbWFwNAABoYwRvlqraLjUZsl6vecJ3u0olu6zX2yPFAACANsfjxFjq1l+9Nmt275JxE6em7v36dPMcbwAAoA0TvGkRtV1qsu3aPVu6DAAAgNK51BwAAABKJHgDAABAiQRvAAAAKJHgDQAAACUSvAEAAKBEgjcAAACUSPAGAACAEgneAAAAUCLBGwAAAEokeAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAiTq0dAFtRVEUSZK6uroWroSWUF9fn+nTp6euri41NTUtXQ5thHlFGcwrymBeUQbzijI057yak/3mZMGFEbybybvvvpsk6du3bwtXAgAAwNLy7rvvpra2dqFtKkVT4jmL1NDQkIkTJ6Zr166pVCotXQ5LWV1dXfr27Zv//ve/6datW0uXQxthXlEG84oymFeUwbyiDM05r4qiyLvvvpvVV1897dot/C5uZ7ybSbt27bLmmmu2dBm0sG7duvk/BpqdeUUZzCvKYF5RBvOKMjTXvFrUme45LK4GAAAAJRK8AQAAoESCNzSDTp065fTTT0+nTp1auhTaEPOKMphXlMG8ogzmFWVoqXllcTUAAAAokTPeAAAAUCLBGwAAAEokeAMAAECJBG+owvjx43PooYdm4MCBWWGFFfKJT3wip59+embOnDlXuyeeeCLbbbddOnfunL59++b8889voYppTS677LIMGDAgnTt3zpZbbplRo0a1dEm0Euecc04233zzdO3aNb169cpee+2VZ599dq42H3zwQYYNG5ZVVlklK620Uvbee+9Mnjy5hSqmNTr33HNTqVTy3e9+t3GbecWSmDBhQr7+9a9nlVVWyQorrJANN9wwo0ePbtxfFEVOO+20rLbaallhhRUyZMiQPP/88y1YMcu62bNn54c//OFc/41+1lln5aPLmy3teSV4QxWeeeaZNDQ05IorrshTTz2Vn/3sZ/nVr36VU089tbFNXV1dhg4dmv79+2fMmDG54IILcsYZZ+TKK69swcpZ1l133XU57rjjcvrpp+exxx7LZz7zmey6666ZMmVKS5dGK3Dvvfdm2LBhefjhhzNy5MjU19dn6NChmTZtWmObY489NjfffHOuv/763HvvvZk4cWK+/OUvt2DVtCaPPvporrjiimy00UZzbTevWFxvv/12tt1229TU1OS2227L008/nZ/+9KdZeeWVG9ucf/75+fnPf55f/epXeeSRR7Liiitm1113zQcffNCClbMsO++88/LLX/4yv/jFL/Kf//wn5513Xs4///xceumljW2W+rwqgGZ1/vnnFwMHDmx8ffnllxcrr7xyMWPGjMZtJ510UrHOOuu0RHm0EltssUUxbNiwxtezZ88uVl999eKcc85pwaporaZMmVIkKe69996iKIrinXfeKWpqaorrr7++sc1//vOfIknx0EMPtVSZtBLvvvtu8clPfrIYOXJkscMOOxTf+c53iqIwr1gyJ510UvHZz352gfsbGhqKPn36FBdccEHjtnfeeafo1KlT8ac//WlplEgrtMceexSHHHLIXNu+/OUvF/vvv39RFC0zr5zxhmY2derU9OjRo/H1Qw89lO233z4dO3Zs3Lbrrrvm2Wefzdtvv90SJbKMmzlzZsaMGZMhQ4Y0bmvXrl2GDBmShx56qAUro7WaOnVqkjT+bhozZkzq6+vnmmPrrrtu+vXrZ46xSMOGDcsee+wx1/xJzCuWzE033ZTNNtss++yzT3r16pVBgwbl17/+deP+l19+OZMmTZprXtXW1mbLLbc0r1igbbbZJnfeeWeee+65JMnjjz+eBx54IJ/73OeStMy86lDKUWE59cILL+TSSy/NhRde2Lht0qRJGThw4Fztevfu3bjvo5dSQZK88cYbmT17duM8maN379555plnWqgqWquGhoZ897vfzbbbbpsNNtggyYe/ezp27Jju3bvP1bZ3796ZNGlSC1RJa3Httdfmsccey6OPPjrPPvOKJfHSSy/ll7/8ZY477riceuqpefTRR3PMMcekY8eOOfDAAxvnzvz+P9G8YkFOPvnk1NXVZd1110379u0ze/bs/OQnP8n++++fJC0yr5zxhvk4+eSTU6lUFvr18QA0YcKE7Lbbbtlnn31y2GGHtVDlAHMbNmxYxo0bl2uvvbalS6GV++9//5vvfOc7+cMf/pDOnTu3dDm0EQ0NDdlkk01y9tlnZ9CgQTn88MNz2GGH5Ve/+lVLl0Yr9uc//zl/+MMf8sc//jGPPfZYfve73+XCCy/M7373uxaryRlvmI/jjz8+Bx100ELbrLXWWo3fT5w4MTvuuGO22WabeRZN69Onzzwrus553adPn+YpmDalZ8+ead++/XznjTnD4jjqqKNyyy235L777suaa67ZuL1Pnz6ZOXNm3nnnnbnOTppjLMyYMWMyZcqUbLLJJo3bZs+enfvuuy+/+MUvcvvtt5tXLLbVVlst66233lzbPv3pT+evf/1rkv//v5UmT56c1VZbrbHN5MmTs/HGGy+1OmldTjzxxJx88sn5f//v/yVJNtxww7zyyis555xzcuCBB7bIvHLGG+Zj1VVXzbrrrrvQrzn3bE+YMCGDBw/OpptumuHDh6ddu7n/WW299da57777Ul9f37ht5MiRWWeddVxmznx17Ngxm266ae68887GbQ0NDbnzzjuz9dZbt2BltBZFUeSoo47K3//+99x1113z3O6y6aabpqamZq459uyzz+bVV181x1ignXfeOU8++WTGjh3b+LXZZptl//33b/zevGJxbbvttvM87vC5555L//79kyQDBw5Mnz595ppXdXV1eeSRR8wrFmj69Onz/Dd5+/bt09DQkKSF5lUpS7bBcuJ///tfsfbaaxc777xz8b///a947bXXGr/meOedd4revXsXBxxwQDFu3Lji2muvLbp06VJcccUVLVg5y7prr7226NSpU3H11VcXTz/9dHH44YcX3bt3LyZNmtTSpdEKHHnkkUVtbW1xzz33zPV7afr06Y1tvvWtbxX9+vUr7rrrrmL06NHF1ltvXWy99dYtWDWt0UdXNS8K84rFN2rUqKJDhw7FT37yk+L5558v/vCHPxRdunQp/u///q+xzbnnnlt07969uPHGG4snnnii+OIXv1gMHDiweP/991uwcpZlBx54YLHGGmsUt9xyS/Hyyy8Xf/vb34qePXsW3/ve9xrbLO15JXhDFYYPH14kme/XRz3++OPFZz/72aJTp07FGmusUZx77rktVDGtyaWXXlr069ev6NixY7HFFlsUDz/8cEuXRCuxoN9Lw4cPb2zz/vvvF9/+9reLlVdeuejSpUvxpS99aa4/GkJTfDx4m1csiZtvvrnYYIMNik6dOhXrrrtuceWVV861v6GhofjhD39Y9O7du+jUqVOx8847F88++2wLVUtrUFdXV3znO98p+vXrV3Tu3LlYa621iu9///tzPd53ac+rSlEURTnn0gEAAAD3eAMAAECJBG8AAAAokeANAAAAJRK8AQAAoESCNwAAAJRI8AYAAIASCd4AAABQIsEbAAAASiR4A9AsDjrooFQqlQwYMKClS1mgSqWSSqWSM844o6VLWajW8LMEAJpO8AZoo+65557GoPnxry5duqR///7Za6+98sc//jGzZs1q6XIp2ejRo3PKKadkq622yhprrJFOnTqlW7du+cQnPpGvfOUrueKKK/LOO++0dJksZ957773cd999ufDCC/PVr341AwcObPw95Q9PQFvSoaULAGDpe//99/Pqq6/m1VdfzY033piLL744N910U/r06dPSpdHMXnnllRx11FG55ZZb5tk3c+bMvPvuu3nppZfy17/+Nccee2yOPfbY/OAHP8gKK6zQAtWyrBswYEBeeeWVHHjggbn66qurPt6ee+6Ze+65p+rjACzrBG+A5cCRRx6Zb3/7242v33vvvYwePTo//elPM378+Dz66KP54he/mIcffjiVSmWJxrj66qub5T/Ey1QURUuXsFSNHj06n//85zN58uQkH4amr33ta9lmm23Su3fvzJw5M//73/9yxx135O9//3vefPPNnH322dlnn32y8cYbt2zxLBc++m+yR48e2WyzzfLggw/mvffea8GqAJqf4A2wHOjVq1c22GCDubZttdVW2X///bPFFlvkhRdeyKhRo3LLLbdkzz33bKEqaU6TJk2aK3T/4Ac/yA9/+MN07Nhxnrb77rtvLrroovz0pz/N2WefvbRLZTm233775Ygjjsjmm2+etddeO8mHfyASvIG2xj3eAMuxlVdeOaecckrj63/+858tWA3N6YgjjmgM3WeddVbOOuus+YbuObp27Zozzjgjd955Z2pra5dWmSznDj/88Hzta19rDN0AbZXgDbCc22KLLRq/f+WVVxq//+jibPfcc08aGhry29/+NjvuuGN69+6ddu3a5aCDDmpsv6iVuD++ovijjz6ar33ta1lzzTXTqVOnrLHGGjnggAPyn//8p0l1jxs3LkcffXQ23HDDrLzyyqmpqUmfPn0yZMiQnH/++XnttdcWWcNHXX311Y37x48fnxkzZuTCCy/MJptsktra2nTr1i1bbrllLr/88syePXuBdTU0NOSuu+7KCSeckG233TY9e/ZMTU1Nunfvno033jgnnHBCXn311Sa9xyU1bty43HTTTUmSjTfeeK4/rizKdtttl4EDB8533+uvv54f/OAHGTRoULp3757OnTtnwIABOeCAA/LAAw8s9LgDBgxIpVJpnDOPPfZY9t9///Tt2zcrrLBC1l577Rx33HF544035ur34IMPZp999km/fv3SuXPnfOITn8hJJ52Ud999d4FjDR48OJVKJYMHD06SPPvsszn88MMzcODAdO7cOauttlq++tWv5uGHH27Sz+SBBx7IAQcckAEDBqRz587p3r17Bg0alB/84Ad5/fXXF9jv4/+GkuTPf/5zdt5556y66qpZYYUVss466+R73/te3nrrrSbVcsMNN8z18+jevXs222yznHnmmXn77bcX2O/j/z7feeednHbaaVl//fWz4oorpnv37tl+++3zhz/8Yb795/xM5/yO+N3vfjfPoo1zft4AzEcBQJt09913F0mKJMXpp5++wHbPPPNMY7vddtttvv1vu+22YsiQIY2v53wdeOCBje0PPPDAIknRv3//+Y7z0Vouu+yyokOHDvMcL0nRpUuX4t57711gvbNmzSqOPfbYolKpzLf//GqbXw0fN3z48Mb9jz32WLHpppsu8Njbb7998e677863vtNPP32hdc15j3/7298W+B4X9bNclOOOO65xrKuuumqJjvFxt99+e9GtW7eFvq9hw4YVs2fPnm///v37N34uv//974uOHTvO9xif+tSnitdee60oiqK44IILFvg5b7LJJgv8DHbYYYciSbHDDjsU//jHP4oVV1xxvsdo165d8bOf/WyB73n27NnFsGHDFvqea2trixEjRsy3/0f/Dd15553F17/+9QUeZ+2112583/Pz1ltvFTvttNNCa+nVq1fx0EMPzbf/R+fUM888UwwYMGChn+OCfqYL+9phhx0WWP/imDNXlnT+AyyLnPEGWM49+eSTjd+vvvrq821z0kkn5Y477sgXvvCF/O1vf8uYMWPyj3/8I5/73OcWe7zbb789Rx99dNZff/389re/zaOPPpr77rsvxx57bNq1a5fp06fngAMOyMyZM+fb//DDD8/PfvazFEWR1VZbLT/5yU9y991357HHHsvtt9+es846K5/5zGcWu66POuKIIzJmzJjsu++++cc//pHRo0fnj3/8YzbffPMkyX333ZcDDjhgvn1nzZqV1VZbLd/+9rdzzTXX5F//+lfGjBmTG264Id/73vey0korZfr06dlvv/2afHZ/cd17772N3++xxx5VH2/s2LHZc889U1dXl5qamhx77LG5++67M2rUqFxxxRWNZ8gvu+yyRZ5df/zxx/PNb34za6+9duPnf9ddd+XrX/96kuS5557LCSeckL/97W858cQTs+WWW+YPf/hDRo8enX/+85/Zfffdk3x4xvzHP/7xQseaOHFi9ttvv3To0CFnn312HnzwwTz44IP5yU9+km7duqWhoSHHHntsbrjhhvn2P/nkk3PZZZclSQYOHJhf/epXGTVqVO6+++4ce+yxqampydSpU/P5z38+jz/++EJr+eEPf5j/+7//y1577TXXv6E5n88LL7yQY489dr59Z8yYkSFDhuSuu+5K+/btc8ABB+RPf/pTHn744dx///35yU9+klVWWSVTpkzJ7rvvPteVKx83ffr07LnnnnnzzTfzgx/8IPfcc09Gjx6dX//611lzzTWTfPg53n777XP1Gz58eJ588snG3xFf/OIX8+STT871NXz48IX+DACWay2d/AEoR1POeNfX1xdbbbVVY7vf//738+2fpPjBD36w0PGaesY7SbH77rsXM2bMmKfNj3/848Y28zsjfOONNzbu33rrrYu33357gfW8+uqrC6xhUWe8kxRnn332PG3q6+uLXXfdtbHNrbfeOk+bl19+uZg5c+YC6/rvf/9brLHGGkWS4utf//p821R7xrumpqZIUqyxxhpL1P/jNt988yJJ0b59++L222+fZ/9bb71VrLfeeo1nkceNGzdPmzlnMZMU22yzTTFt2rR52nzlK19pHKdHjx7F3nvvXcyaNWuuNrNmzWqcs6usskpRX18/z3E+ena2tra2ePrpp+dpM27cuMYz+GusscY8n9kTTzxRtGvXrkhSbLDBBvOda7fddltjmy222GKe/R//N/TjH/94njYNDQ3F0KFDiyRFhw4diilTpszT5tRTTy2SFN27dy9Gjx49z/6iKIrx48cXq622WpGk2G+//ebZP2dOzfmZzO8zev7554vOnTsXSYovfOEL8x3no1culMUZb6AtcsYbYDk0bdq03Hvvvdlll10a73Pt379/vvrVr863/ac+9an53he9JDp37pzhw4fPd6GvY445pnH7/fffP8/+c889N0nSpUuX/OUvf0n37t0XOE7fvn2XuMaNNtooJ5988jzbO3TokN/85jepqalJklx++eXztBkwYEDj/vlZc801c+KJJyZJbrrppmZ/xFldXV3q6+uTfLiafbVGjRqVRx99NEly2GGHZejQofO0WXnllXPllVcm+fAe9/n9XOaoVCr5zW9+ky5dusyzb84j72bPnp0PPvggV155Zdq3bz9Xm/bt2+fwww9Pkrz55pt5+umnF1r/D3/4w3z605+eZ/v666+f73//+0mSCRMm5MYbb5xr/y9/+cs0NDQkSX7zm9/Md67ttttuOeSQQ5LM/XOan0033TSnnnrqPNsrlUqOO+64JB9eLfHQQw/Ntf+9995rPOt+1llnZdNNN53v8fv3758f/vCHSZLrr78+06ZNW2AtZ511VtZff/15tq+99trZa6+9kmSR9+wDsHgEb4DlwJlnnjnXIkgrrbRSBg8e3LjgU69evXLDDTekU6dO8+2/7777zhOAltQuu+yywEDYtWvXfPKTn0ySvPTSS3Pte/PNNxv/SLDvvvsu8LL45nDggQcu8Hnma665ZmP4vOeeexa60FryYRB++eWX89RTT2XcuHEZN25cY+ics685fXTRsRVXXLHq491xxx2N3x966KELbLfttts2BtyP9vm4jTbaaL5BOMlctwjssssu6dGjxyLbfXyefFSlUsmBBx64wP0HH3xw4+f88ZrnvF5//fWz5ZZbLvAYhx122Dx95me//fZb4Jz6aJj++Pu59957M3Xq1CTJV77ylQUeP0m23377JEl9fX3GjBkz3zaVSiX77bffAo8xp5a33nor77zzzkLHA6DpBG+A5djAgQNz4okn5sknn8zGG2+8wHYbbbRRs4257rrrLnT/nLD18VWrx44d23h2eLvttmu2euZnzr3cCzJnJfhp06bNN/i98sorOfroozNgwIDU1tZmrbXWygYbbJANN9wwG264YeMZ2yTzrOJdra5duzZ+v7Cznk01bty4JEnHjh0XOkeSNAbU559/foH36H/qU59aYP+PnlVuaruFrW4+cODA9OzZc4H7V1111cZVvj+61sGMGTPy/PPPJ8lCQ3eSDBo0qPEKhzk/q/lZ2Lz/6B8YPv5+Ro8e3fj9aqutNs9K4h/92mCDDRrbTpo0ab5j9ezZM6usssoS1QLAkuvQ0gUAUL4jjzyy8TLeSqWSzp07p2fPnk1+XvPKK6/cbLXM7xLjj2rX7sO/CX/8TPJHA+pqq63WbPXMz6Iu0e7du3fj9x9/DNRtt92Wr3zlK5k+fXqTxnr//fcXv8CF6NatW2pqalJfX9/4HO9qzHl/PXr0SIcOC//Phj59+iRJiqLI22+/PdfPaY6Fff5zPvvFabewKw6acql979698/LLL8/1OX70sVyLOkZNTU1WWWWVTJo0aaGPBFvS9zNlypSFjr8gC5p/Tf33N79aAFhygjfAcqBXr15znQ1bXM11mXlrsaBLghfljTfeyH777Zfp06dnpZVWygknnJBdd901n/jEJ1JbW9t4//pdd92VnXfeOUma/R7v5MMrFMaMGZOJEydm8uTJ8w3Ai2tJfyYtqTlqbun3/dHw+9hjjy10/YCPmrNCOQDLBsEbgFbho5cMv/baa6WONXny5IVe6vzRM8kfvTT3L3/5S+N9sX//+98zZMiQ+fZf2JnR5rDDDjs03uN76623Ni4AtiTmvL8333wzs2bNWuhZ7zmXN1cqlWa9SmJJNeWM/5w2H/0cP1r7oo4xa9asvPnmm/Mco7l89LLwVVddVaAGaKXc4w1AqzBo0KDGs4/33XdfqWMtbHXqj+7v0qVL1lprrcbtTz31VJIPA9iCQncy9327ZTjooIMav7/00ksbV+deEnOulJg5c2bGjh270LajRo1Kknzyk5+c76r1S9vLL7/cGIrn5/XXX8/48eOTZK4rQjp16tS4yN8jjzyy0DH+/e9/N64iX81VJQsyaNCgxu//9a9/NfvxF1dLXwEA0FoJ3gC0Cj169Mg222yTJPnzn/+ciRMnljbWNddcs8BLwCdMmJARI0YkSQYPHjzXZfizZs1KknzwwQcLDLvTp0/PNddc08wVz23DDTfMF77whSQfLkp39tlnN7nvAw88MNdK6x/9A8Jvf/vbBfZ76KGHGh/ttbA/OixNRVHk97///QL3X3311Y2f88drnvP6qaeeavyDwvz85je/madPcxoyZEjjfdk///nPS7k1YXF07tw5yYcL0AHQdII3AK3GSSedlOTD8LrPPvs0PmZpfv73v/8t8Thjx47NBRdcMM/2WbNm5bDDDmtcsfvII4+ca/+cs6TTp0/Pn//853n6z549O9/85jdL/aPBHFdccUXjvd0//OEPc9pppy1wpfHkwxXQzzzzzOy0005z/Vy32GKLbLbZZkmSX//617nzzjvn6Tt16tQcccQRST5cnOvjP5eWdNZZZ+XZZ5+dZ/t//vOf/OQnP0ny4WJ9X/ziF+faf+SRRzYuNHb44Yenrq5unmOMGDEiV111VZIPf06LWg1/SXTv3j1HHXVUkuTBBx/Mscceu9ArGCZPnjzXHwOa25yFDV988cXSxgBoi9zjDUCrseeee+bQQw/NVVddlQcffDDrrbdejjrqqGy77bbp1q1b3njjjYwePTrXXXddPvOZz+Tqq69eonE222yznHTSSRk7dmy+8Y1vpFevXnn++edz0UUXNZ793HPPPfP5z39+rn5f/epXc+qpp2bGjBk5+OCDM3bs2Oyyyy6pra3NU089lUsvvTRjxozJtttuW/plw3369Mktt9ySz3/+85k8eXLOOuusXHPNNdlvv/2y7bbbplevXpk5c2YmTJiQu+66K3/961/z+uuvz/dYv/71r7Pllltm5syZ2X333XP00Udnzz33zIorrph///vfOffccxsfq3bCCSeUcsn1klh77bXz+uuvZ6uttspJJ52UwYMHJ/nw+evnnntu4x8YLr300nkujd9www1z/PHH54ILLsjjjz+eTTbZJCeddFIGDRqUadOm5eabb87Pf/7zzJ49Ox07dswVV1xR2vv40Y9+lHvvvTePPPJILrnkktxzzz057LDDsvHGG2fFFVfM22+/naeeeip33HFHbrvttmy44Yb55je/WUot22yzTe6+++48+uijOffcc/O5z32u8XnxK6ywQtZYY43FOt4LL7yQBx54YK5t7733XuP/fvzf8G677da4ej5Aq1IA0CbdfffdRZIiSXH66adX1f/uu+9eZPsDDzywSFL0799/vvubWssOO+xQJCl22GGH+e6fNWtWcdRRRxWVSqXxmPP7OvDAAxerhuHDhzfuf+yxx4pBgwYt8NjbbrttUVdXN9/6fvvb3xbt2rVbYN999923uOOOOxb6s13Uz3JxjB8/vthjjz0W+rOa87XiiisWZ5xxRvHBBx/Mc5zbb7+96Nat20L7Dxs2rJg9e/Z86+jfv/8CP5ePaso8efnllxvbDR8+fJ79H51Dt9xyS9GlS5f51tuuXbviwgsvXOA4s2fPLr797W8v9D3X1tYWt99++3z7L86/oUW977q6uuLLX/5ykz7HHXfccZ7+TZ1TH/138PLLL8+z/3//+1/Ro0eP+Y67oH+zTR2vKV9N+V0EsCxyqTkArUr79u1z6aWXZvTo0Tn88MPzqU99KiuuuGJqamrSp0+fDB06NBdddFEuvPDCJR5j5ZVXzoMPPphzzjknG2+8cbp27ZqVVlopm2++eS699NLce++96dq163z7Hnzwwbn//vuz1157ZdVVV01NTU1WW2217Lbbbrnuuuty7bXXLtXHs/Xv3z+33HJLRo0alZNOOilbbLFFVltttXTs2DErrbRS1lprrXzlK1/JlVdemYkTJ+b0009Pp06d5jnO0KFD88ILL+TUU0/NxhtvnG7duqVTp07p169f9t9//9x///35xS9+MddzoJcFe+yxR0aPHp2DDz44/fv3T8eOHdOrV6/svffeeeCBB3L88ccvsG+7du1y2WWX5b777sv++++ffv36pVOnTunWrVs23njjnHrqqXn++eczdOjQ0t9H165d89e//jX3339/vvnNb2adddZJ165d06FDh/To0SObb755hg0bln/84x8ZOXJkaXWsscYaGTVqVA499NCsvfbajfd8A7BwlaJo4VU6AGAZcPXVV+fggw9O8uFq2AMGDGjZglhigwcPzr333psddtgh99xzT0uXAwAWVwMAAIAyCd4AAABQIsEbAAAASiR4AwAAQIkEbwAAACiRVc0BAACgRM54AwAAQIkEbwAAACiR4A0AAAAlErwBAACgRII3AAAAlEjwBgAAgBIJ3gAAAFAiwRsAAABKJHgDAABAif4/qykIekosEewAAAAASUVORK5CYII=", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAMWCAYAAADs4eXxAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAABloElEQVR4nO39eZxWdf0//j8uYABRGEQEXNjMytwS9yUVFdE0yzLzm2ZuqRlqueVSuWS5Z5ppaRlW70qzxTUT3DUXBENFcxctEHBlFBQG5vz+8Md8RLaBaw6zeL/fbnNrrnNer/N6XnO9mHzMOed1KkVRFAEAAACaXYeWLgAAAADaK6EbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJJ0aukC2ouGhoZMnjw53bt3T6VSaelyAAAAKFFRFHn77bez+uqrp0OHRZ/PFrqbyeTJk9O/f/+WLgMAAIDl6L///W/WXHPNRe4XuptJ9+7dk7z/A+/Ro0cLV8PyVl9fn1GjRmX48OGpqalp6XJoJ8wrymBeUQbzijKYV5ShOedVXV1d+vfv35gFF0XobibzLinv0aOH0P0RVF9fn27duqVHjx7+T4FmY15RBvOKMphXlMG8ogxlzKsl3V5sITUAAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoSaeWLgA+SqbPrM+EydMz/d361K5Qk/VXr01tt5qWLgsAACiJ0A3LyROTp+e2J6eloSgat42d+GaGrdsn661e24KVAQAAZXF5OSwH02fWLxC4k6ShKHLbk9MyfWZ9C1UGAACUSeiG5WDC5OkLBO55GooiEyZPX84VAQAAy4PQDcvB9HcXfya7bgn7AQCAtknohuWgdoXFL5bWYwn7AQCAtknohuVg/dVr06FSWei+DpVK1reQGgAAtEtCNywHtd1qMmzdPgsE7w6VSnZet6/HhgEAQDvlkWGwnKy3em3W7NktEyZPT9279enhOd0AANDuCd2wHNV2q8k2a/du6TIAAIDlxOXlAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQko9c6D7nnHNSqVTyne98p3Hbe++9lxEjRmSVVVbJSiutlL322itTp05tuSIBAABoFz5Sofvhhx/O5Zdfng033HC+7cccc0xuvPHGXHvttbn77rszefLkfOlLX2qhKgEAAGgvPjKh+5133sl+++2XX/3qV1l55ZUbt0+fPj1XXnllLrzwwuy4447ZZJNNMnLkyNx///158MEHW7BiAAAA2rqPTOgeMWJEdt999wwbNmy+7ePGjUt9ff1829dZZ50MGDAgDzzwwPIuEwAAgHakU0sXsDxcffXVeeSRR/Lwww8vsG/KlCnp3LlzevbsOd/2vn37ZsqUKYs85qxZszJr1qzG13V1dUmS+vr61NfXN0/htBnzPnOfPc3JvKIM5hVlMK8og3lFGZpzXjX1GO0+dP/3v//Nt7/97YwePTpdu3ZttuOeffbZOeOMMxbYPmrUqHTr1q3ZxqFtGT16dEuXQDtkXlEG84oymFeUwbyiDM0xr2bOnNmkdpWiKIqqR2vFrrvuunzxi19Mx44dG7fNnTs3lUolHTp0yK233pphw4blzTffnO9s98CBA/Od73wnxxxzzEKPu7Az3f37989rr72WHj16lPZ+aJ3q6+szevTo7LzzzqmpqWnpcmgnzCvKYF5RBvOKMphXlKE551VdXV169+6d6dOnLzYDtvsz3TvttFMef/zx+bYddNBBWWeddXLiiSemf//+qampye2335699torSfL000/n5ZdfzlZbbbXI43bp0iVdunRZYHtNTY1fCh9hPn/KYF5RBvOKMphXlMG8ogzNMa+a2r/dh+7u3btn/fXXn2/biiuumFVWWaVx+yGHHJJjjz02vXr1So8ePXLUUUdlq622ypZbbtkSJQMAANBOtPvQ3RQ//elP06FDh+y1116ZNWtWdtlll1x22WUtXRYAAABt3EcydN91113zve7atWsuvfTSXHrppS1TEAAAAO3SR+Y53QAAALC8Cd0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAl6dTSBVC+6TPrM2Hy9Ex/tz61K9Rk/dVrU9utpqXLAgAAaPeE7nbuicnTc9uT09JQFI3bxk58M8PW7ZP1Vq9twcoAAADaP5eXt2PTZ9YvELiTpKEoctuT0zJ9Zn0LVQYAAPDRIHS3YxMmT18gcM/TUBSZMHn6cq4IAADgo0Xobsemv7v4M9l1S9gPAABAdYTudqx2hcUvltZjCfsBAACojtDdjq2/em06VCoL3dehUsn6FlIDAAAoldDdjtV2q8mwdfssELw7VCrZed2+HhsGAABQMo8Ma+fWW702a/bslgmTp6fu3fr08JxuAACA5Ubo/gio7VaTbdbu3dJlAAAAfOS4vBwAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQknYfus8+++xsttlm6d69e/r06ZM999wzTz/99Hxt3nvvvYwYMSKrrLJKVlpppey1116ZOnVqC1UMAABAe9HuQ/fdd9+dESNG5MEHH8zo0aNTX1+f4cOHZ8aMGY1tjjnmmNx444259tprc/fdd2fy5Mn50pe+1IJVAwAA0B50aukCyvbPf/5zvtdXXXVV+vTpk3HjxmW77bbL9OnTc+WVV+aPf/xjdtxxxyTJyJEj86lPfSoPPvhgttxyy5YoGwAAgHag3YfuD5s+fXqSpFevXkmScePGpb6+PsOGDWtss84662TAgAF54IEHFhm6Z82alVmzZjW+rqurS5LU19envr6+rPJppeZ95j57mpN5RRnMK8pgXlEG84oyNOe8auoxPlKhu6GhId/5zneyzTbbZP3110+STJkyJZ07d07Pnj3na9u3b99MmTJlkcc6++yzc8YZZyywfdSoUenWrVuz1k3bMXr06JYugXbIvKIM5hVlMK8og3lFGZpjXs2cObNJ7T5SoXvEiBGZMGFC7rvvvqqPdfLJJ+fYY49tfF1XV5f+/ftn+PDh6dGjR9XHp22pr6/P6NGjs/POO6empqaly6GdMK8og3lFGcwrymBeUYbmnFfzrnZeko9M6D7yyCNz00035Z577smaa67ZuL1fv36ZPXt23nrrrfnOdk+dOjX9+vVb5PG6dOmSLl26LLC9pqbGL4WPMJ8/ZTCvKIN5RRnMK8pgXlGG5phXTe3f7lcvL4oiRx55ZP7+97/njjvuyODBg+fbv8kmm6Smpia3335747ann346L7/8crbaaqvlXS4AAADtSLs/0z1ixIj88Y9/zPXXX5/u3bs33qddW1ubFVZYIbW1tTnkkENy7LHHplevXunRo0eOOuqobLXVVlYuBwAAoCrtPnT/4he/SJIMHTp0vu0jR47MgQcemCT56U9/mg4dOmSvvfbKrFmzsssuu+Syyy5bzpUCAADQ3rT70F0UxRLbdO3aNZdeemkuvfTS5VARAAAAHxXt/p5uAAAAaClCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAAStJpeQ84d+7cTJo0KUkyYMCA5T08AAAALDdLfaZ75syZ+dGPfpSNNtoo3bt3T8+ePbPVVlvloosuynvvvbfE/k899VQGDRqUtdZaa5kKBgAAgLZiqc50//e//83OO++cZ599NklSFEWSZMyYMRkzZkwuuuii/Pa3v83222+/xGPN6wsAAADtVZPPdM+dOzdf/vKX88wzz6QoitTU1GTTTTfNBhtskI4dO6Yoirz88svZaaed8pOf/KTMmgEAAKBNaHLo/stf/pKHH344lUolX/nKVzJ58uSMGTMmjz76aCZNmpTjjjsunTp1SkNDQ7773e/mpJNOKrNuAAAAaPWaHLqvvvrqJMlmm22WP/3pT+nVq1fjvlVXXTXnn39+7r333qy55popiiLnn39+vvnNbzZ/xQAAANBGNDl0jx07NpVKJUcffXQqlcpC22yxxRZ5+OGHs/HGG6coivzqV7/Kfvvtl4aGhmYrGAAAANqKJofu1157LUnyqU99arHt+vbtm7vuuitDhw5NURS5+uqrs9dee6W+vr66SgEAAKCNaXLo7tDh/aZNeSzYSiutlFtuuSWf+9znUhRFbrjhhnzuc5/Lu+++u+yVAgAAQBvT5NC9xhprJEnj48KWpEuXLvn73/+effbZJ0VR5Lbbbssuu+ySurq6ZasUAAAA2pgmh+4NN9wwSXL77bc3+eAdO3bMH//4xxxyyCEpiiL/+te/ss8++yx9lQAAANAGNTl0z7tH++9//3tmzJjR5AEqlUp+9atf5Tvf+U6KosikSZOWqVAAAABoa5ocuvfYY49UKpXMmDEjv/jFL5Z6oAsvvDCnnnpqiqJY6r4AAADQFnVqasOBAwfme9/7Xl555ZW8/vrryzTY6aefnlVWWSV/+9vflqk/AAAAtCVNDt1J8sMf/rDqAY866qgcddRRVR8HAAAAWrsmX14OAAAALB2hGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASlJV6N5xxx2z00475aWXXmpyn8mTJzf2AwAAgPasUzWd77rrrlQqlcyYMaPJfd59993GfgAAANCeubwcAAAASrLcQ/e8s+Jdu3Zd3kMDAADAcrXcQ/ctt9ySJFlzzTWX99AAAACwXC3VPd0HH3zwQrd///vfT8+ePRfbd9asWXn++efz8MMPp1KpZPvtt1+aoQEAAKDNWarQfdVVVy2wAFpRFLn++uub1L8oiiRJr169cvLJJy/N0AAAANDmLFXoHjBgwHyh+6WXXkqlUslqq62WmpqaRfarVCrp2rVrVltttWy99dY54ogjsvrqqy971QAAANAGLFXonjhx4nyvO3R4/5bwUaNGZd111222ogAAAKA9qOo53dttt10qlUpWXHHF5qoHAAAA2o2qQvddd93VTGUAAABA+7PcHxkGAAAAHxVVnelemLq6urz99tuZO3fuEtsOGDCguYcHAACAVqNZQvfo0aNz2WWX5b777ssbb7zRpD6VSiVz5sxpjuEBAACgVao6dB999NG59NJLk/y/53ADAAAAVYbuP/7xj/n5z3+eJOnatWv23HPPbLLJJunVq1fj48QAAADgo6qq0H355ZcnSfr375877rgjH/vYx5qlKAAAAGgPqjod/dhjj6VSqeS0004TuAEAAOBDqgrd9fX1SZIhQ4Y0SzEAAADQnlQVugcNGpQkeeedd5qjFgAAAGhXqgrdX/rSl5Ikt99+e7MUAwAAAO1JVaH7uOOOy4ABA3LRRRflqaeeaq6aAAAAoF2oKnTX1tbm1ltvTd++fbP11lvnsssuy5tvvtlctQEAAECbVtUjw9Zaa60kycyZM/PWW2/lqKOOytFHH53evXunW7dui+1bqVTy/PPPVzM8AAAAtGpVhe6JEyfO97ooihRFkWnTpi2xb6VSqWZoAAAAaPWqCt0HHHBAc9UBAAAA7U5VoXvkyJHNVQcAAAC0O1UtpAYAAAAsmtANAAAAJanq8vIPe/fddzNu3LhMmTIlM2fOzJ577pkePXo05xAAAADQZjRL6P7vf/+bU045Jddee23q6+sbt2+66aZZd911G19feeWVufzyy1NbW5tRo0ZZwRwAAIB2rerLyx966KEMGTIkf/zjHzN79uzGx4YtzB577JHHHnssd9xxR0aNGlXt0AAAANCqVRW633rrrXzhC1/IG2+8kX79+uWyyy7L448/vsj2ffr0yWc/+9kkyc0331zN0AAAANDqVXV5+c9+9rNMmzYtvXv3zgMPPJABAwYssc+wYcNy/fXXZ8yYMdUMDQAAAK1eVWe6b7zxxlQqlRx77LFNCtxJst566yVJnn/++WqGBgAAgFavqtD93HPPJUm22267JvdZeeWVkyR1dXXVDA0AAACtXlWh+7333kuS1NTUNLnPjBkzkiQrrLBCNUMDAABAq1dV6O7Tp0+S5MUXX2xyn/HjxydJVl999WqGBgAAgFavqtC9xRZbJEluueWWJrUviiK/+tWvUqlUsu2221YzNAAAALR6VYXu/fbbL0VR5A9/+EPjGezFOe644/Loo48mSQ444IBqhgYAAIBWr6rQ/YUvfCE77LBD5syZk5122im/+MUvMm3atMb9c+bMyeTJk3Pttddm2223zcUXX5xKpZIvfelL2XrrrasuHgAAAFqzqp7TnSR//etfs9NOO+Xf//53jjzyyBx55JGpVCpJkiFDhszXtiiKbLnllrnqqquqHRYAAABavarOdCdJz54988ADD+Tkk09Ojx49UhTFQr9WWGGFfPe7381dd92VFVdcsTlqBwAAgFat6jPdSdK5c+f8+Mc/zimnnJK77747Y8eOzbRp0zJ37tysssoqGTJkSIYNG5ba2trmGA4AAADahGYJ3fOsuOKK2W233bLbbrs152EBAACgTar68nIAAABg4YRuAAAAKEmzXV7++uuv54EHHsgLL7yQt99+O3Pnzl1in1NPPbW5hgcAAIBWp+rQPW3atBxzzDH5y1/+kjlz5ixVX6EbAACA9qyqy8vffPPNfOYzn8nVV1+d+vr6RT4ubFFfrc2ll16aQYMGpWvXrtliiy0yZsyYli4JAACANqyq0H3OOefkueeeS1EUGT58eP75z3/m1Vdfzdy5c9PQ0LDEr9bkmmuuybHHHpvTTjstjzzySD796U9nl112ybRp01q6NAAAANqoqkL39ddfn0qlks997nP55z//meHDh2eVVVZJpVJprvqWmwsvvDCHHnpoDjrooKy77rr55S9/mW7duuU3v/lNS5cGAABAG1XVPd0vv/xykmTEiBHNUkxLmT17dsaNG5eTTz65cVuHDh0ybNiwPPDAAwvtM2vWrMyaNavxdV1dXZKkvr4+9fX15RZMqzPvM/fZ05zMK8pgXlEG84oymFeUoTnnVVOPUVXoXmmllTJr1qz07du3msO0uNdeey1z585d4H307ds3Tz311EL7nH322TnjjDMW2D5q1Kh069atlDpp/UaPHt3SJdAOmVeUwbyiDOYVZTCvKENzzKuZM2c2qV1VoXuDDTbIXXfdlZdeeikbbbRRNYdqc04++eQce+yxja/r6urSv3//DB8+PD169GjBymgJ9fX1GT16dHbeeefU1NS0dDm0E+YVZTCvKIN5RRnMK8rQnPNq3tXOS1JV6D788MNz55135ve//32+8IUvVHOoFtW7d+907NgxU6dOnW/71KlT069fv4X26dKlS7p06bLA9pqaGr8UPsJ8/pTBvKIM5hVlMK8og3lFGZpjXjW1f1ULqX3lK1/Jfvvtl7///e8555xzqjlUi+rcuXM22WST3H777Y3bGhoacvvtt2errbZqwcoAAABoy6o6033PPffkkEMOyYsvvpjvfe97+dvf/pZ9990366yzTpPua95uu+2qGb5ZHXvssTnggAOy6aabZvPNN89FF12UGTNm5KCDDmrp0gAAAGijqgrdQ4cOne/xYOPGjcu4ceOa1LdSqWTOnDnVDN+s9tlnn7z66qs59dRTM2XKlGy00Ub55z//2eYXiQMAAKDlVBW6k6Qoiuaoo1U48sgjc+SRR7Z0GQAAALQTVYXuO++8s7nqAAAAgHanqtC9/fbbN1cdAAAAkOkz6zNh8vRMf7c+tSvUZP3Va1Pbre2uYF/15eUAAADQHJ6YPD23PTktDR+4jXnsxDczbN0+WW/12hasbNlV9cgwAAAAaA7TZ9YvELiTpKEoctuT0zJ9Zn0LVVadZj3TPW7cuNx2222ZMGFC3njjjSRJr169sv7662fYsGHZZJNNmnM4AAAA2okJk6cvELjnaSiKTJg8Pdus3Xs5V1W9Zgndjz/+eA477LCMGTNmkW1OOeWUbLHFFrn88suzwQYbNMewAAAAtBPT3138mey6Jexvraq+vPy2227L5ptvnjFjxqQoihRFkU6dOqVv377p27dvOnXq1Lj9wQcfzOabb57bb7+9OWoHAACgnahdYfGLpfVYwv7WqqrQ/dprr2XvvffOrFmzUqlU8o1vfCMPPfRQZsyYkcmTJ2fy5MmZOXNmxowZk0MPPTQdO3bMrFmzsvfee+f1119vrvcAAABAG7f+6rXpUKksdF+HSiXrfxQXUrv44oszffr0dO7cOTfffHOuuOKKbLbZZunU6f9dtd6xY8dsuummufzyy3PzzTenpqYm06dPz8UXX1x18QAAALQPtd1qMmzdPgsE7w6VSnZet2+bfWxYVaH75ptvTqVSyZFHHplddtllie2HDx+eo446KkVR5Oabb65maAAAANqZ9VavzYFbD8rmg3tlnX7ds/ngXjlw60FZd/UeLV3aMqsqdL/44otJks9//vNN7jOv7QsvvFDN0AAAALRDtd1qss3avfPZDVbLNmv3brNnuOepKnS/9957SZIVV1yxyX3mtZ01a1Y1QwMAAECrV1Xo7tevX5Lk3//+d5P7zGvbt2/faoYGAACAVq+q0L3tttumKIqcc845qaurW2L7t99+O+eee24qlUq23XbbaoYGAACAVq+q0H344Ycnef/e7u222y5jx45dZNuxY8dm++23z/PPPz9fXwAAAGivOi25yaJts802+da3vpXLLrssjz/+eLbYYoust9562WKLLdKnT59UKpVMnTo1Dz30UJ544onGft/61reyzTbbVF08AAAAtGZVhe4kueSSS9KtW7dceOGFaWhoyIQJE+YL2ElSFEWSpEOHDjn++ONzzjnnVDssAAAAtHpVXV6eJJVKJeedd17Gjx+fI444Ih//+MdTFMV8Xx//+MdzxBFHZPz48Y33dAMAAEB7V/WZ7nnWX3/9XHrppUmS2bNn580330ySrLzyyuncuXNzDQMAAABtRrOF7g/q3LmzR4IBAADwkVf15eUAAADAwjXbme65c+fm+uuvz2233ZbHH388b7zxRpKkV69eWX/99TNs2LB84QtfSKdOpZxcBwAAgFanWRLwDTfckCOPPDKTJk1q3DZvxfJKpZL7778/V1xxRVZbbbX8/Oc/z5577tkcwwIAAECrVvXl5RdffHG++MUvZtKkSY1Be9CgQdlyyy2z5ZZbZtCgQUneD+GTJ0/OXnvtlYsuuqjaYQEAAKDVqyp0P/TQQznuuONSFEW6d++ec889N1OnTs3zzz+f+++/P/fff3+ef/75TJ06Neeee25qa2tTFEVOOOGEPPTQQ831HgAAAKBVqip0X3jhhWloaEhtbW3uv//+nHDCCendu/cC7Xr37p0TTjgh999/f2pra9PQ0JALL7ywmqEBAACg1asqdN97772pVCo58cQTs+666y6x/ac+9amceOKJKYoi99xzTzVDAwAAQKtXVeh+8803kyQ77LBDk/vMa/vWW29VMzQAAAC0elWF7tVWW61F+gIAAEBbUFXoHjZsWJLk7rvvbnKfu+66K0my4447VjM0AAAAtHpVhe7jjjsuK6ywQs4555w888wzS2z/zDPP5Nxzz82KK66YE044oZqhAQAAoNWrKnR/8pOfzF/+8pckyZZbbpmLLroob7zxxgLt3nzzzVx88cXZeuutkyR//vOf88lPfrKaoQEAAKDV61RN53mXiK+66qp59tlnc9xxx+X444/P4MGD06dPn1QqlUydOjUvvvhiiqJIkqy99to5//zzc/755y/0mJVKJbfffns1ZQEAAECrUFXovuuuu1KpVBpfF0WRoijy/PPP5/nnn19on+eeey7PPfdcYwifp1KppCiK+Y4HAAAAbVlVoXu77bYTkgEAAGARqj7TDQAAACxcVQupAQAAAIsmdAMAAEBJhG4AAAAoSVX3dH9QQ0NDnnzyybzwwgt5++23M3fu3CX2+frXv95cwwMAAECrU3XonjlzZn70ox/l17/+dV5//fUm96tUKkI3AAAA7VpVofudd97JDjvskEceeWSB524DAADAR11VoftHP/pRxo0blyTZcsstc9hhh+XTn/50evbsmQ4d3C4OAADAR1tVofsvf/lLKpVKdtttt1x//fWCNgAAAHxAVSl50qRJSZKjjz5a4AYAAIAPqSop9+nTJ0nSu3fvZikGAAAA2pOqQvfmm2+eJHn66aebpRgAAABoT6oK3cccc0yS5Oc//7nVywEAAOBDqgrdW2+9dc4999zcf//9+f/+v/8vb731VjOVBQAAAG1fVauXJ8nxxx+fj33sYzn00EPTv3//7LzzzvnEJz6Rbt26LbHvqaeeWu3wAAAA0GpVHbqnTZuWv//975k+fXoaGhpy/fXXN7mv0A0AAEB7VlXofv3117Pddtvl2WefdU83AAAAfEhV93SfddZZeeaZZ1IURb785S/njjvuyOuvv565c+emoaFhiV8AAADQnlV1pvuGG25IpVLJ1772tfz2t79trpoAAACgXajqTPekSZOSJAcffHCzFAMAAADtSVWhu3fv3kmS7t27N0sxAAAA0J5UFbq33XbbJMmECROapRgAAABoT6oK3ccdd1xqampywQUX5L333muumgAAAKBdqCp0b7zxxvn1r3+dZ555JsOHD88zzzzTXHUBAABAm1fV6uXzFlBbd911c99992XdddfNhhtumE984hPp1q3bYvtWKpVceeWV1QwPAAAArVpVofuqq65KpVJJ8n6IbmhoyKOPPppHH310sf2KohC6AQAAaPeqCt0DBgxoDN0AAADA/KoK3RMnTmymMgAAAKD9qWohNQAAAGDRhG4AAAAoSVWXly/KnDlz8uabbyZJVl555XTqVMowAAAA0Ko125nu//znPznqqKPyqU99Kl27dk2/fv3Sr1+/dO3aNZ/61Kdy9NFH58knn2yu4QAAAKDVa5bQffLJJ2fDDTfMZZddlqeffjoNDQ0piiJFUaShoSFPP/10Lr300nz605/OKaec0hxDAgAAQKtX9XXfRx11VC677LIURZEk+dSnPpUtttgi/fr1S5JMmTIlY8aMyZNPPpm5c+fm3HPPzYwZM3LxxRdXOzQAAAC0alWF7n/961+59NJLU6lUsu666+aKK67I1ltvvdC2DzzwQL75zW/m8ccfz89//vPss88+i2wLAAAA7UFVl5dffvnlSZLBgwfnX//612JD9FZbbZV77rkna621VpLkl7/8ZTVDAwAAQKtXVei+9957U6lUctJJJ6W2tnaJ7Wtra3PiiSemKIrce++91QwNAAAArV5VoXvKlClJkiFDhjS5z8Ybb5wkmTp1ajVDAwAAQKtXVeju2rVrkmTGjBlN7jOvbZcuXaoZGgAAAFq9qkL34MGDkyQ33nhjk/vMazvv3m4AAABor6oK3bvttluKosgll1yS22+/fYnt77zzzlxyySWpVCrZbbfdqhkaAAAAWr2qQvd3vvOd9OjRI/X19fnsZz+bI488Mo888kgaGhoa2zQ0NOSRRx7JkUcemV133TWzZ89Ojx498p3vfKfa2gEAAKBVq+o53b17986f//znfP7zn8/s2bPzi1/8Ir/4xS/SuXPn9OrVK5VKJa+//npmz56dJCmKIp07d861116bVVZZpVneAAAAALRWVZ3pTpLhw4fnwQcfzKabbpqiKFIURWbNmpVXXnklkydPzqxZsxq3b7rppnnooYcybNiw5qgdAAAAWrWqznTPs9FGG2XMmDF5+OGHc9ttt2XChAl54403kiS9evXK+uuvn2HDhmWzzTZrjuEAAACgTWiW0D3PZpttJlgDAADA/1/Vl5cDAAAAC7dUZ7pnzZqVp59+OklSW1ubgQMHNrnvSy+9lOnTpydJPvWpT6WmpmZphgYAAIA2Z6nOdJ955pkZMmRINt988/zvf/9bqoH+97//ZbPNNsuQIUNy7rnnLlVfAAAAaIuaHLqnT5+en/70p0mSk08+Odtss81SDbTNNtvklFNOSVEUOe+88/LOO+8sXaUAAADQxjQ5dF9zzTV59913s8oqq+T4449fpsFOOOGErLrqqpkxY0auueaaZToGAAAAtBVNDt2jR49OpVLJF7/4xay44orLNFi3bt2y1157pSiKjBo1apmOAQAAAG1Fk0P3v//97yTJsGHDqhpwxx13TJI88sgjVR0HAAAAWrsmh+5XX301SbLmmmtWNeAaa6yRJJk2bVpVxwEAAIDWrsmhe9asWUmSzp07VzXgvP6zZ8+u6jgAAADQ2jU5dPfu3TtJMnXq1KoGnHeGe5VVVqnqOAAAANDaNTl09+/fP0ly//33VzXgv/71r/mOBwAAAO1Vk0P3DjvskKIo8qc//Slz5sxZpsHq6+vzxz/+MZVKJTvssMMyHQMAAADaiiaH7r322itJMnHixPzoRz9apsF+/OMfZ+LEifMdDwAAANqrJofuTTbZJLvvvnuKosiZZ56Zs88+O0VRNHmgs846Kz/84Q9TqVSy++67Z5NNNlmmggEAAKCtaHLoTpJLL700ffv2TZJ8//vfz6abbprf/va3jY8T+7BXX301V111VTbZZJP84Ac/SJL07ds3l156aZVlAwAAQOvXaWkaDxgwIDfeeGP22GOPTJ06NePHj8/BBx+c5P3nb/fp0ycrrrhiZsyYkalTp2by5MmNfYuiSN++fXPjjTdaRA0AAICPhKUK3Umy6aabZvz48TnssMNy4403Nm6fNGlSJk2aNF/bD15+/vnPfz6XX35545lyAAAAaO+WOnQn718ifv311+eJJ57IyJEjc/fdd+exxx5LfX19Y5uamppsuOGG2X777XPggQdm/fXXb7aiAQAAoC1YptA9z3rrrZcLLrig8fXbb7+dt99+O927d0/37t2rLg4AAADasqpC94cJ2wAAAPD/LNXq5QAAAEDTCd0AAABQknYduidOnJhDDjkkgwcPzgorrJCPfexjOe200zJ79uz52j322GPZdttt07Vr1/Tv3z/nnXdeC1UMAABAe9Ks93S3Nk899VQaGhpy+eWXZ+21186ECRNy6KGHZsaMGY0LwNXV1WX48OEZNmxYfvnLX+bxxx/PwQcfnJ49e+awww5r4XcAAABAW9auQ/euu+6aXXfdtfH1Wmutlaeffjq/+MUvGkP3H/7wh8yePTu/+c1v0rlz56y33noZP358LrzwQqEbAACAqrTry8sXZvr06enVq1fj6wceeCDbbbddOnfu3Lhtl112ydNPP50333yzJUoEAACgnWjXZ7o/7Lnnnssll1wy37PFp0yZksGDB8/Xrm/fvo37Vl555YUea9asWZk1a1bj67q6uiRJfX196uvrm7t0Wrl5n7nPnuZkXlEG84oymFeUwbyiDM05r5p6jDYZuk866aSce+65i23zn//8J+uss07j60mTJmXXXXfN3nvvnUMPPbTqGs4+++ycccYZC2wfNWpUunXrVvXxaZtGjx7d0iXQDplXlMG8ogzmFWUwryhDc8yrmTNnNqldpSiKourRlrNXX301r7/++mLbrLXWWo2XjE+ePDlDhw7NlltumauuuiodOvy/q+q//vWvp66uLtddd13jtjvvvDM77rhj3njjjaU6092/f/+89tpr6dGjRxXvjraovr4+o0ePzs4775yampqWLod2wryiDOYVZTCvKIN5RRmac17V1dWld+/emT59+mIzYJs8073qqqtm1VVXbVLbSZMmZYcddsgmm2ySkSNHzhe4k2SrrbbK9773vdTX1zf+0EePHp1PfvKTiwzcSdKlS5d06dJlge01NTV+KXyE+fwpg3lFGcwrymBeUQbzijI0x7xqav92vZDapEmTMnTo0AwYMCAXXHBBXn311UyZMiVTpkxpbLPvvvumc+fOOeSQQ/LEE0/kmmuuycUXX5xjjz22BSsHAACgPWjSme611lqr2QeuVCp5/vnnm/24HzR69Og899xzee6557LmmmvOt2/eVfW1tbUZNWpURowYkU022SS9e/fOqaee6nFhAAAAVK1JoXvixInNPnClUmn2Y37YgQcemAMPPHCJ7TbccMPce++9pdcDAADAR0uTQvcBBxxQdh0AAADQ7jQpdI8cObLsOgAAAKDdadcLqQEAAEBLEroBAACgJEI3AAAAlKRJ93QvjYkTJ+a1117Lu+++2/hYrkXZbrvtmnt4AAAAaDWaJXQ//fTTOeuss3LDDTekrq6uSX0qlUrmzJnTHMMDAABAq1R16L7uuuuy33775b333lvimW0AAAD4KKkqdP/3v//N1772tbz77rtZY401csIJJ6Rbt2457LDDUqlUctttt+WNN97I2LFj8/vf/z6TJ0/OZz7zmZx++unp2LFjc70HAAAAaJWqCt0/+9nPMnPmzHTv3j0PPfRQVl999TzxxBON+3fYYYckyV577ZVTTz01hxxySK655ppceeWV+cMf/lBd5QAAANDKVbV6+W233ZZKpZJvfetbWX311RfbdoUVVsj//d//ZciQIbn66qvz17/+tZqhAQAAoNWrKnRPnDgxSbL11ls3bqtUKo3ff3ihtA4dOuToo49OURT5zW9+U83QAAAA0OpVFbpnzJiRJOnfv3/jtm7dujV+P3369AX6rLfeekmSRx99tJqhAQAAoNWrKnTX1tYmSd57773Gbausskrj988///wCfeYF8ddee62aoQEAAKDVqyp0f/KTn0ySvPDCC43bunfvnoEDByZJRo0atUCf0aNHJ0l69uxZzdAAAADQ6lUVurfaaqskyYMPPjjf9s997nMpiiLnn39+7rzzzsbtf/7zn3PxxRenUqlkm222qWZoAAAAaPWqCt277bZbiqLI3/72t8ydO7dx+7zndb/zzjsZNmxYVl111XTv3j1f/epX895776VDhw454YQTqi4eAAAAWrOqQvfQoUNz2mmn5aCDDsqkSZMatw8YMCDXXnttamtrUxRFXn/99cyYMSNFUaRLly751a9+lS233LLq4gEAAKA161RN50qlktNOO22h+z772c/m2WefzV/+8pc88cQTmTNnTj7+8Y/nK1/5StZYY41qhgUAAIA2oarQvSSrrLJKDj/88DKHAAAAgFarqsvLAQAAgEVr9jPdRVHkhRdeyBtvvJEk6dWrV9Zaa61UKpXmHgoAAABatWYL3f/85z9z2WWX5a677sqMGTPm29etW7cMHTo03/rWt/LZz362uYYEAACAVq3qy8tnzpyZvfbaK7vvvntuvvnmvPPOOymKYr6vGTNm5B//+Ec+97nP5Ytf/OICoRwAAADao6rOdDc0NGS33XbLvffem6IoUlNTk+HDh2fzzTdP3759kyRTp07Nww8/nFGjRmX27Nm54YYbsttuu+Wuu+5yyTkAAADtWlWh+/LLL88999yTSqWSXXbZJb/+9a8X+TiwSZMm5dBDD80///nP3HffffnlL3+ZI444oprhAQAAoFWr6vLy3/72t0mSzTbbLDfffPNin7+9xhpr5MYbb8zmm2+eoiga+wIAAEB7VVXo/s9//pNKpZJjjjkmHTos+VAdO3bMscce29gXAAAA2rOqQve8e7I/8YlPNLnPxz/+8fn6AgAAQHtVVej+2Mc+liSZNm1ak/vMazuvLwAAALRXVYXur371qymKIr/73e+a3Od3v/tdKpVK9tlnn2qGBgAAgFavqtB99NFHZ+ONN87VV1+d8847b4ntzz///PzpT3/KkCFD8p3vfKeaoQEAAKDVq+qRYVOmTMmvf/3rHH744Tn55JPzpz/9KQcccEA222yz9OnTJ5VKpfE53b///e8zfvz4bLbZZrniiisyZcqURR53wIAB1ZQFAAAArUJVoXvQoEHzLYj22GOP5bjjjltsn7Fjx2bjjTde5P5KpZI5c+ZUUxYAAAC0ClWF7iQpiqI56gAAAIB2p6rQPXLkyOaqAwAAANqdqkL3AQcc0Fx1AAAAQLtT1erlAAAAwKIJ3QAAAFASoRsAAABK0qR7un/4wx82fn/qqacudPuy+OCxAAAAoL1pUug+/fTTG5/H/cGg/MHty0LoBgAAoD1r8urli3oet+d0AwAAwMI1KXQ3NDQs1XYAAADAQmoAAABQGqEbAAAASiJ0AwAAQEmqCt1TpkzJwQcfnIMPPjiTJk1aYvtJkybl4IMPziGHHJI33nijmqEBAACg1asqdP/+97/PVVddlfHjx2eNNdZYYvs11lgj48ePz1VXXZX/+7//q2ZoAAAAaPWqCt2jRo1KpVLJl7/85Sb32WeffVIURW655ZZqhgYAAIBWr6rQPWHChCTJ5ptv3uQ+m266aZLkscceq2ZoAAAAaPWqCt2vv/56kmTVVVdtcp/evXvP1xcAAADaq6pC90orrZQkmT59epP71NXVJUk6d+5czdAAAADQ6lUVutdcc80kyQMPPNDkPv/617+SpEkLrwEAAEBbVlXoHjp0aIqiyCWXXNJ4Bntx6urq8vOf/zyVSiVDhw6tZmgAAABo9aoK3YcffngqlUpeeeWV7L777pk6deoi206ZMiW77757Jk+enEqlksMPP7yaoQEAAKDV61RN5/XWWy/f/va3c9FFF+X+++/P2muvnX322SfbbrttVltttSTJK6+8knvuuSd//vOfM3PmzFQqlYwYMSIbbbRRc9QPAAAArVZVoTtJLrjggkyfPj0jR47MjBkzMnLkyIwcOXKBdkVRJEm+8Y1v5KKLLqp2WAAAAGj1qrq8PEk6dOiQK6+8Mtddd1222mqrJO8H7A9+Jck222yTG264IVdccUUqlUq1wwIAAECrV/WZ7nk+//nP5/Of/3zeeOONjB8/Pq+99lqS95/LPWTIkKy88srNNRQAAAC0Cc0Wuufp1atXdtxxx+Y+LAAAALQ5VV9eDgAAACyc0A0AAAAlaZbLy+fMmZObb7459957b1544YW8/fbbmTt37mL7VCqV3H777c0xPAAAALRKVYfu++67L/vvv39efvnlxm3zVixfmEqlkqIorGAOAABAu1dV6H7qqaey66675t13301RFOncuXM+/vGPp1evXunQwZXrAAAAfLRVFbrPOuuszJw5Mx07dswZZ5yRo48+OiuttFJz1QYAAABtWlWh+4477kilUsm3v/3tnHLKKc1VEwAAALQLVV0D/tprryVJvvjFLzZLMQAAANCeVBW6V1111STJCius0CzFAAAAQHtSVej+zGc+kySZMGFCsxQDAAAA7UlVofvYY49Nx44dc/HFF2fOnDnNVRMAAAC0C1WF7s022ywXXXRRHn300XzpS19qvMcbAAAAqHL18h/+8IdJks033zw33XRTBg4cmJ133jnrrLNOunXrtsT+p556ajXDAwAAQKtWVeg+/fTTU6lUkiSVSiXvvvtubrzxxtx4441N6i90AwAA0J5VFbqTpCiKxb4GAACAj6qqQndDQ0Nz1QEAAADtTlULqQEAAACLJnQDAABASYRuAAAAKInQDQAAACVp0kJqa621VpL3Hwv2/PPPL7B9WXz4WAAAANDeNCl0T5w4MUkan8n94e3L4sPHAgAAgPamSaH7gAMOWKrtAAAAQBND98iRI5dqOwAAAGAhNQAAAChNk850L8rBBx+cJPnsZz+bvffeu1kKAgAAgPaiqtD929/+Nkmyzz77NEsxAAAA0J5UdXn5qquumiTp27dvsxQDAAAA7UlVoXvddddNkrz00kvNUgwAAAC0J1WF7q997WspiqLxMnMAAADg/6kqdB900EHZaaedcv311+f0009PURTNVRcAAAC0eVUtpHbvvffm+OOPz6uvvpozzzwz11xzTfbZZ59suOGGWXnlldOxY8fF9t9uu+2qGR4AAABatapC99ChQ1OpVBpfP/PMMznzzDOb1LdSqWTOnDnVDA8AAACtWlWhO4lLygEAAGARqgrdd955Z3PVAQAAAO1OVaF7++23b646AAAAoN2pavVyAAAAYNGW6Uz3zTffnH/+85956aWXMnfu3Ky++uoZOnRovvKVr6Smpqa5awQAAIA2aalC99SpU7PnnntmzJgxC+z7zW9+k1NPPTXXXXddNthgg2YrEAAAANqqJl9ePnfu3Hz+85/PQw89lKIoFvr14osvZpdddslrr71WZs0AAADQJjQ5dP/5z3/Oww8/nEqlkrXXXjtXXnllHn/88Tz11FO59tprs+WWWyZ5/2z4T37yk9IKBgAAgLZiqUJ3kgwaNChjxozJQQcdlPXWWy+f+MQnstdee+Xee+/N9ttvn6Iocu2115ZWMAAAALQVTQ7d//73v1OpVHLcccelZ8+eC+zv2LFjzjjjjCTJiy++mLfffrvZimwOs2bNykYbbZRKpZLx48fPt++xxx7Ltttum65du6Z///4577zzWqZIAAAA2pUmh+5XX301SbLpppsuss0H97W2+7q/+93vZvXVV19ge11dXYYPH56BAwdm3LhxOf/883P66afniiuuaIEqAQAAaE+avHr5u+++m0qlkpVWWmmRbbp169b4/XvvvVddZc3olltuyahRo/LXv/41t9xyy3z7/vCHP2T27Nn5zW9+k86dO2e99dbL+PHjc+GFF+awww5roYoBAABoD5p8pntpFUVR1qGXytSpU3PooYfm97///Xx/FJjngQceyHbbbZfOnTs3bttll13y9NNP580331yepQIAANDOLNVzutuaoihy4IEH5pvf/GY23XTTTJw4cYE2U6ZMyeDBg+fb1rdv38Z9K6+88kKPPWvWrMyaNavxdV1dXZKkvr4+9fX1zfQOaCvmfeY+e5qTeUUZzCvKYF5RBvOKMjTnvGrqMZY6dF922WXp06dPs7Q79dRTl3b4JMlJJ52Uc889d7Ft/vOf/2TUqFF5++23c/LJJy/TOItz9tlnNy4c90GjRo1a6Bl1PhpGjx7d0iXQDplXlMG8ogzmFWUwryhDc8yrmTNnNqldpWjideAdOnRIpVKpqqgPmzt37jL1e/XVV/P6668vts1aa62Vr3zlK7nxxhvnq3vu3Lnp2LFj9ttvv/z2t7/N17/+9dTV1eW6665rbHPnnXdmxx13zBtvvLFUZ7r79++f1157LT169Fim90XbVV9fn9GjR2fnnXdOTU1NS5dDO2FeUQbzijKYV5TBvKIMzTmv6urq0rt370yfPn2xGXCpznQ3533a1QT4VVddNauuuuoS2/3sZz/Lj370o8bXkydPzi677JJrrrkmW2yxRZJkq622yve+973U19c3/tBHjx6dT37yk4sM3EnSpUuXdOnSZYHtNTU1fil8hPn8KYN5RRnMK8pgXlEG84oyNMe8amr/JofuO++8c5mLaSkDBgyY7/W8ldc/9rGPZc0110yS7LvvvjnjjDNyyCGH5MQTT8yECRNy8cUX56c//elyrxcAAID2pcmhe/vtty+zjhZTW1ubUaNGZcSIEdlkk03Su3fvnHrqqR4XBgAAQNXa9erlHzZo0KCFXiK/4YYb5t57722BigAAAGjPSntONwAAAHzUCd0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlOQjEbpvvvnmbLHFFllhhRWy8sorZ88995xv/8svv5zdd9893bp1S58+fXLCCSdkzpw5LVMsAAAA7Uanli6gbH/9619z6KGH5qyzzsqOO+6YOXPmZMKECY37586dm9133z39+vXL/fffn1deeSVf//rXU1NTk7POOqsFKwcAAKCta9ehe86cOfn2t7+d888/P4ccckjj9nXXXbfx+1GjRuXJJ5/Mbbfdlr59+2ajjTbKmWeemRNPPDGnn356Onfu3BKlAwAA0A6069D9yCOPZNKkSenQoUOGDBmSKVOmZKONNsr555+f9ddfP0nywAMPZIMNNkjfvn0b++2yyy454ogj8sQTT2TIkCELPfasWbMya9asxtd1dXVJkvr6+tTX15f4rmiN5n3mPnuak3lFGcwrymBeUQbzijI057xq6jHadeh+4YUXkiSnn356LrzwwgwaNCg/+clPMnTo0DzzzDPp1atXpkyZMl/gTtL4esqUKYs89tlnn50zzjhjge2jRo1Kt27dmvFd0JaMHj26pUugHTKvKIN5RRnMK8pgXlGG5phXM2fObFK7Nhm6TzrppJx77rmLbfOf//wnDQ0NSZLvfe972WuvvZIkI0eOzJprrplrr702hx9++DLXcPLJJ+fYY49tfF1XV5f+/ftn+PDh6dGjxzIfl7apvr4+o0ePzs4775yampqWLod2wryiDOYVZTCvKIN5RRmac17Nu9p5Sdpk6D7uuONy4IEHLrbNWmutlVdeeSXJ/Pdwd+nSJWuttVZefvnlJEm/fv0yZsyY+fpOnTq1cd+idOnSJV26dFlge01NjV8KH2E+f8pgXlEG84oymFeUwbyiDM0xr5rav02G7lVXXTWrrrrqEtttsskm6dKlS55++ul85jOfSfL+XzYmTpyYgQMHJkm22mqr/PjHP860adPSp0+fJO9fatCjR4/5wjoAAAAsrTYZupuqR48e+eY3v5nTTjst/fv3z8CBA3P++ecnSfbee+8kyfDhw7Puuutm//33z3nnnZcpU6bk+9//fkaMGLHQM9kAAADQVO06dCfJ+eefn06dOmX//ffPu+++my222CJ33HFHVl555SRJx44dc9NNN+WII47IVlttlRVXXDEHHHBAfvjDH7Zw5QAAALR17T5019TU5IILLsgFF1ywyDYDBw7MP/7xj+VYFQAAAB8FHVq6AAAAAGivhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAknRq6QL4aJo+sz4TJk/P9HfrU7tCTdZfvTa13WpauiwAAIBmJXSz3D0xeXpue3JaGoqicdvYiW9m2Lp9st7qtS1YGQAAQPNyeTnL1fSZ9QsE7iRpKIrc9uS0TJ9Z30KVAQAAND+hm+VqwuTpCwTueRqKIhMmT1/OFQEAAJRH6Ga5mv7u4s9k1y1hPwAAQFsidLNc1a6w+MXSeixhPwAAQFsidLNcrb96bTpUKgvd16FSyfoWUgMAANoRoZvlqrZbTYat22eB4N2hUsnO6/b12DAAAKBd8cgwlrv1Vq/Nmj27ZcLk6al7tz49PKcbAABop4RuWkRtt5pss3bvli4DAACgVC4vBwAAgJII3QAAAFASoRsAAABKInQDAABASYRuAAAAKInQDQAAACURugEAAKAkQjcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEmEbgAAACiJ0A0AAAAlEboBAACgJEI3AAAAlEToBgAAgJII3QAAAFCSTi1dQHtRFEWSpK6uroUroSXU19dn5syZqaurS01NTUuXQzthXlEG84oymFeUwbyiDM05r+Zlv3lZcFGE7mby9ttvJ0n69+/fwpUAAACwvLz99tupra1d5P5KsaRYTpM0NDRk8uTJ6d69eyqVSkuXw3JWV1eX/v3757///W969OjR0uXQTphXlMG8ogzmFWUwryhDc86roijy9ttvZ/XVV0+HDou+c9uZ7mbSoUOHrLnmmi1dBi2sR48e/k+BZmdeUQbzijKYV5TBvKIMzTWvFneGex4LqQEAAEBJhG4AAAAoidANzaBLly457bTT0qVLl5YuhXbEvKIM5hVlMK8og3lFGVpiXllIDQAAAEriTDcAAACUROgGAACAkgjdAAAAUBKhG6owceLEHHLIIRk8eHBWWGGFfOxjH8tpp52W2bNnz9fusccey7bbbpuuXbumf//+Oe+881qoYtqSSy+9NIMGDUrXrl2zxRZbZMyYMS1dEm3E2Wefnc022yzdu3dPnz59sueee+bpp5+er817772XESNGZJVVVslKK62UvfbaK1OnTm2himmLzjnnnFQqlXznO99p3GZesSwmTZqUr33ta1lllVWywgorZIMNNsjYsWMb9xdFkVNPPTWrrbZaVlhhhQwbNizPPvtsC1ZMazd37tz84Ac/mO+/0c8888x8cDmz5TmvhG6owlNPPZWGhoZcfvnleeKJJ/LTn/40v/zlL3PKKac0tqmrq8vw4cMzcODAjBs3Lueff35OP/30XHHFFS1YOa3dNddck2OPPTannXZaHnnkkXz605/OLrvskmnTprV0abQBd999d0aMGJEHH3wwo0ePTn19fYYPH54ZM2Y0tjnmmGNy44035tprr83dd9+dyZMn50tf+lILVk1b8vDDD+fyyy/PhhtuON9284ql9eabb2abbbZJTU1Nbrnlljz55JP5yU9+kpVXXrmxzXnnnZef/exn+eUvf5mHHnooK664YnbZZZe89957LVg5rdm5556bX/ziF/n5z3+e//znPzn33HNz3nnn5ZJLLmlss1znVQE0q/POO68YPHhw4+vLLrusWHnllYtZs2Y1bjvxxBOLT37yky1RHm3E5ptvXowYMaLx9dy5c4vVV1+9OPvss1uwKtqqadOmFUmKu+++uyiKonjrrbeKmpqa4tprr21s85///KdIUjzwwAMtVSZtxNtvv118/OMfL0aPHl1sv/32xbe//e2iKMwrls2JJ55YfOYzn1nk/oaGhqJfv37F+eef37jtrbfeKrp06VL86U9/Wh4l0gbtvvvuxcEHHzzfti996UvFfvvtVxTF8p9XznRDM5s+fXp69erV+PqBBx7Idtttl86dOzdu22WXXfL000/nzTffbIkSaeVmz56dcePGZdiwYY3bOnTokGHDhuWBBx5owcpoq6ZPn54kjb+bxo0bl/r6+vnm2DrrrJMBAwaYYyzRiBEjsvvuu883fxLzimVzww03ZNNNN83ee++dPn36ZMiQIfnVr37VuP/FF1/MlClT5ptXtbW12WKLLcwrFmnrrbfO7bffnmeeeSZJ8uijj+a+++7LZz/72STLf151avYjwkfYc889l0suuSQXXHBB47YpU6Zk8ODB87Xr27dv474PXj4FSfLaa69l7ty5jfNknr59++app55qoapoqxoaGvKd73wn22yzTdZff/0k7//u6dy5c3r27Dlf2759+2bKlCktUCVtxdVXX51HHnkkDz/88AL7zCuWxQsvvJBf/OIXOfbYY3PKKafk4YcfztFHH53OnTvngAMOaJw7C/v/RPOKRTnppJNSV1eXddZZJx07dszcuXPz4x//OPvtt1+SLPd55Uw3LMRJJ52USqWy2K8Ph59JkyZl1113zd57751DDz20hSoHmN+IESMyYcKEXH311S1dCm3cf//733z729/OH/7wh3Tt2rWly6GdaGhoyMYbb5yzzjorQ4YMyWGHHZZDDz00v/zlL1u6NNqwP//5z/nDH/6QP/7xj3nkkUfy29/+NhdccEF++9vftkg9znTDQhx33HE58MADF9tmrbXWavx+8uTJ2WGHHbL11lsvsEBav379Fli5dd7rfv36NU/BtCu9e/dOx44dFzpvzBmWxpFHHpmbbrop99xzT9Zcc83G7f369cvs2bPz1ltvzXdW0hxjccaNG5dp06Zl4403btw2d+7c3HPPPfn5z3+eW2+91bxiqa222mpZd91159v2qU99Kn/961+T/L//Vpo6dWpWW221xjZTp07NRhtttNzqpG054YQTctJJJ+X/+//+vyTJBhtskJdeeilnn312DjjggOU+r5zphoVYddVVs8466yz2a9492pMmTcrQoUOzySabZOTIkenQYf5/VltttVXuueee1NfXN24bPXp0PvnJT7q0nIXq3LlzNtlkk9x+++2N2xoaGnL77bdnq622asHKaCuKosiRRx6Zv//977njjjsWuMVlk002SU1NzXxz7Omnn87LL79sjrFIO+20Ux5//PGMHz++8WvTTTfNfvvt1/i9ecXS2mabbRZ4pOEzzzyTgQMHJkkGDx6cfv36zTev6urq8tBDD5lXLNLMmTMX+G/yjh07pqGhIUkLzKtmX5oNPkL+97//FWuvvXax0047Ff/73/+KV155pfFrnrfeeqvo27dvsf/++xcTJkworr766qJbt27F5Zdf3oKV09pdffXVRZcuXYqrrrqqePLJJ4vDDjus6NmzZzFlypSWLo024Igjjihqa2uLu+66a77fSzNnzmxs881vfrMYMGBAcccddxRjx44tttpqq2KrrbZqwappiz64enlRmFcsvTFjxhSdOnUqfvzjHxfPPvts8Yc//KHo1q1b8X//93+Nbc4555yiZ8+exfXXX1889thjxRe+8IVi8ODBxbvvvtuCldOaHXDAAcUaa6xR3HTTTcWLL75Y/O1vfyt69+5dfPe7321sszznldANVRg5cmSRZKFfH/Too48Wn/nMZ4ouXboUa6yxRnHOOee0UMW0JZdcckkxYMCAonPnzsXmm29ePPjggy1dEm3Eon4vjRw5srHNu+++W3zrW98qVl555aJbt27FF7/4xfn+YAhN8eHQbV6xLG688cZi/fXXL7p06VKss846xRVXXDHf/oaGhuIHP/hB0bdv36JLly7FTjvtVDz99NMtVC1tQV1dXfHtb3+7GDBgQNG1a9dirbXWKr73ve/N9wjf5TmvKkVRFM1//hwAAABwTzcAAACUROgGAACAkgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwDN4sADD0ylUsmgQYNaupRFqlQqqVQqOf3001u6lMVqCz9LAKBphG6Aduquu+5qDJkf/urWrVsGDhyYPffcM3/84x8zZ86cli6Xko0dOzYnn3xyttxyy6yxxhrp0qVLevTokY997GP58pe/nMsvvzxvvfVWS5fJR8w777yTe+65JxdccEG+8pWvZPDgwY2/p/zRCWgvOrV0AQAsf++++25efvnlvPzyy7n++utz0UUX5YYbbki/fv1aujSa2UsvvZQjjzwyN9100wL7Zs+enbfffjsvvPBC/vrXv+aYY47JMccck+9///tZYYUVWqBaWrtBgwblpZdeygEHHJCrrrqq6uPtscceueuuu6o+DkBrJnQDfAQcccQR+da3vtX4+p133snYsWPzk5/8JBMnTszDDz+cL3zhC3nwwQdTqVSWaYyrrrqqWf4jvExFUbR0CcvV2LFj87nPfS5Tp05N8n5g+upXv5qtt946ffv2zezZs/O///0vt912W/7+97/n9ddfz1lnnZW99947G220UcsWz0fCB/9N9urVK5tuumnuv//+vPPOOy1YFUDzEroBPgL69OmT9ddff75tW265Zfbbb79svvnmee655zJmzJjcdNNN2WOPPVqoSprTlClT5gvc3//+9/ODH/wgnTt3XqDtPvvskwsvvDA/+clPctZZZy3vUvkI23fffXP44Ydns802y9prr53k/T8OCd1Ae+KeboCPsJVXXjknn3xy4+t//vOfLVgNzenwww9vDNxnnnlmzjzzzIUG7nm6d++e008/Pbfffntqa2uXV5l8xB122GH56le/2hi4AdojoRvgI27zzTdv/P6ll15q/P6DC7HdddddaWhoyG9+85vssMMO6du3bzp06JADDzywsf2SVtz+8MrhDz/8cL761a9mzTXXTJcuXbLGGmtk//33z3/+858m1T1hwoQcddRR2WCDDbLyyiunpqYm/fr1y7Bhw3LeeefllVdeWWINH3TVVVc17p84cWJmzZqVCy64IBtvvHFqa2vTo0ePbLHFFrnssssyd+7cRdbV0NCQO+64I8cff3y22Wab9O7dOzU1NenZs2c22mijHH/88Xn55Zeb9B6X1YQJE3LDDTckSTbaaKP5/rCyJNtuu20GDx680H2vvvpqvv/972fIkCHp2bNnunbtmkGDBmX//ffPfffdt9jjDho0KJVKpXHOPPLII9lvv/3Sv3//rLDCCll77bVz7LHH5rXXXpuv3/3335+99947AwYMSNeuXfOxj30sJ554Yt5+++1FjjV06NBUKpUMHTo0SfL000/nsMMOy+DBg9O1a9esttpq+cpXvpIHH3ywST+T++67L/vvv38GDRqUrl27pmfPnhkyZEi+//3v59VXX11kvw//G0qSP//5z9lpp52y6qqrZoUVVsgnP/nJfPe7380bb7zRpFquu+66+X4ePXv2zKabbpozzjgjb7755iL7ffjf51tvvZVTTz016623XlZcccX07Nkz2223Xf7whz8stP+8n+m83xG//e1vF1igcd7PG4APKQBol+68884iSZGkOO200xbZ7qmnnmpst+uuuy60/y233FIMGzas8fW8rwMOOKCx/QEHHFAkKQYOHLjQcT5Yy6WXXlp06tRpgeMlKbp161bcfffdi6x3zpw5xTHHHFNUKpWF9l9YbQur4cNGjhzZuP+RRx4pNtlkk0Uee7vttivefvvthdZ32mmnLbauee/xb3/72yLf45J+lkty7LHHNo515ZVXLtMxPuzWW28tevTosdj3NWLEiGLu3LkL7T9w4MDGz+V3v/td0blz54Ue4xOf+ETxyiuvFEVRFOeff/4iP+eNN954kZ/B9ttvXyQptt9+++If//hHseKKKy70GB06dCh++tOfLvI9z507txgxYsRi33NtbW0xatSohfb/4L+h22+/vfja1762yOOsvfbaje97Yd54441ixx13XGwtffr0KR544IGF9v/gnHrqqaeKQYMGLfZzXNTPdHFf22+//SLrXxrz5sqyzn+A1saZboCPuMcff7zx+9VXX32hbU488cTcdttt+fznP5+//e1vGTduXP7xj3/ks5/97FKPd+utt+aoo47Keuutl9/85jd5+OGHc8899+SYY45Jhw4dMnPmzOy///6ZPXv2Qvsfdthh+elPf5qiKLLaaqvlxz/+ce6888488sgjufXWW3PmmWfm05/+9FLX9UGHH354xo0bl3322Sf/+Mc/Mnbs2Pzxj3/MZpttliS55557sv/++y+075w5c7LaaqvlW9/6Vn7/+9/nX//6V8aNG5frrrsu3/3ud7PSSitl5syZ2XfffZt8Vn9p3X333Y3f77777lUfb/z48dljjz1SV1eXmpqaHHPMMbnzzjszZsyYXH755Y1nxi+99NIlnlV/9NFH841vfCNrr7124+d/xx135Gtf+1qS5Jlnnsnxxx+fv/3tbznhhBOyxRZb5A9/+EPGjh2bf/7zn9ltt92SvH+m/Ec/+tFix5o8eXL23XffdOrUKWeddVbuv//+3H///fnxj3+cHj16pKGhIcccc0yuu+66hfY/6aSTcumllyZJBg8enF/+8pcZM2ZM7rzzzhxzzDGpqanJ9OnT87nPfS6PPvroYmv5wQ9+kP/7v//LnnvuOd+/oXmfz3PPPZdjjjlmoX1nzZqVYcOG5Y477kjHjh2z//77509/+lMefPDB3Hvvvfnxj3+cVVZZJdOmTctuu+023xUrHzZz5szsscceef311/P9738/d911V8aOHZtf/epXWXPNNZO8/zneeuut8/UbOXJkHn/88cbfEV/4whfy+OOPz/c1cuTIxf4MAD6yWjr1A1COppzprq+vL7bccsvGdr/73e8W2j9J8f3vf3+x4zX1THeSYrfdditmzZq1QJsf/ehHjW0Wdib4+uuvb9y/1VZbFW+++eYi63n55ZcXWcOSznQnKc4666wF2tTX1xe77LJLY5ubb755gTYvvvhiMXv27EXW9d///rdYY401iiTF1772tYW2qfZMd01NTZGkWGONNZap/4dtttlmRZKiY8eOxa233rrA/jfeeKNYd911G88eT5gwYYE2885eJim23nrrYsaMGQu0+fKXv9w4Tq9evYq99tqrmDNnznxt5syZ0zhnV1lllaK+vn6B43zwrGxtbW3x5JNPLtBmwoQJjWfu11hjjQU+s8cee6zo0KFDkaRYf/31FzrXbrnllsY2m2+++QL7P/xv6Ec/+tECbRoaGorhw4cXSYpOnToV06ZNW6DNKaecUiQpevbsWYwdO3aB/UVRFBMnTixWW221Ikmx7777LrB/3pya9zNZ2Gf07LPPFl27di2SFJ///OcXOs4Hr1goizPdQHvjTDfAR9CMGTNy9913Z+edd268r3XgwIH5yle+stD2n/jEJxZ6H/Sy6Nq1a0aOHLnQRb2OPvroxu333nvvAvvPOeecJEm3bt3yl7/8JT179lzkOP3791/mGjfccMOcdNJJC2zv1KlTfv3rX6empiZJctllly3QZtCgQY37F2bNNdfMCSeckCS54YYbmv0xZnV1damvr0/y/qr11RozZkwefvjhJMmhhx6a4cOHL9Bm5ZVXzhVXXJHk/XvaF/ZzmadSqeTXv/51unXrtsC+eY+1mzt3bt57771cccUV6dix43xtOnbsmMMOOyxJ8vrrr+fJJ59cbP0/+MEP8qlPfWqB7eutt16+973vJUkmTZqU66+/fr79v/jFL9LQ0JAk+fWvf73Qubbrrrvm4IMPTjL/z2lhNtlkk5xyyikLbK9UKjn22GOTvH+VxAMPPDDf/nfeeafxbPuZZ56ZTTbZZKHHHzhwYH7wgx8kSa699trMmDFjkbWceeaZWW+99RbYvvbaa2fPPfdMkiXeow9A0wndAB8BZ5xxxnwLHq200koZOnRo4+JOffr0yXXXXZcuXbostP8+++yzQPhZVjvvvPMiw2D37t3z8Y9/PEnywgsvzLfv9ddfb/wDwT777LPIS+GbwwEHHLDI55WvueaajcHzrrvuWuyiasn7IfjFF1/ME088kQkTJmTChAmNgXPevub0wQXGVlxxxaqPd9tttzV+f8ghhyyy3TbbbNMYbj/Y58M23HDDhYbgJPPdFrDzzjunV69eS2z34XnyQZVKJQcccMAi9x900EGNn/OHa573er311ssWW2yxyGMceuihC/RZmH333XeRc+qDQfrD7+fuu+/O9OnTkyRf/vKXF3n8JNluu+2SJPX19Rk3btxC21Qqley7776LPMa8Wt5444289dZbix0PgKYRugE+wgYPHpwTTjghjz/+eDbaaKNFtttwww2bbcx11llnsfvnBa0Pr049fvz4xrPC2267bbPVszDz7t1elHkrvs+YMWOhoe+ll17KUUcdlUGDBqW2tjZrrbVW1l9//WywwQbZYIMNGs/UJllgte5qde/evfH7xZ3tbKoJEyYkSTp37rzYOZKkMZw+++yzi7wn/xOf+MQi+3/wbHJT2y1uFfPBgwend+/ei9y/6qqrNq7m/cG1DWbNmpVnn302SRYbuJNkyJAhjVc2zPtZLczi5v0H/7jw4fczduzYxu9XW221BVYM/+DX+uuv39h2ypQpCx2rd+/eWWWVVZapFgCWTaeWLgCA8h1xxBGNl+5WKpV07do1vXv3bvLzmFdeeeVmq2VhlxV/UIcO7/89+MNnkD8YTldbbbVmq2dhlnRZdt++fRu///Cjnm655ZZ8+ctfzsyZM5s01rvvvrv0BS5Gjx49UlNTk/r6+sbndFdj3vvr1atXOnVa/H829OvXL0lSFEXefPPN+X5O8yzu85/32S9Nu8VdadCUy+v79u2bF198cb7P8YOP3lrSMWpqarLKKqtkypQpi33s17K+n2nTpi12/EVZ1Pxr6r+/hdUCwLIRugE+Avr06TPfWbCl1VyXlrcVi7oMeElee+217Lvvvpk5c2ZWWmmlHH/88dlll13ysY99LLW1tY33q99xxx3ZaaedkqTZ7+lO3r8yYdy4cZk8eXKmTp260PC7tJb1Z9KSmqPmln7fHwy+jzzyyGLXC/igeSuRA9DyhG4A2oQPXib8yiuvlDrW1KlTF3t58wfPIH/wcty//OUvjffB/v3vf8+wYcMW2n9xZ0Sbw/bbb994T+/NN9/cuNjXspj3/l5//fXMmTNnsWe7513SXKlUmvXqiGXVlDP989p88HP8YO1LOsacOXPy+uuvL3CM5vLBS8FXXXVVYRqgDXJPNwBtwpAhQxrPOt5zzz2ljrW4Vag/uL9bt25Za621Grc/8cQTSd4PX4sK3Mn89+mW4cADD2z8/pJLLmlchXtZzLtCYvbs2Rk/fvxi244ZMyZJ8vGPf3yhq9Mvby+++GJjIF6YV199NRMnTkyS+a4E6dKlS+OCfg899NBix/j3v//duFp8NVeTLMqQIUMav//Xv/7V7MdfWi195h+gLRK6AWgTevXqla233jpJ8uc//zmTJ08ubazf//73i7zse9KkSRk1alSSZOjQofNdej9nzpwkyXvvvbfIoDtz5sz8/ve/b+aK57fBBhvk85//fJL3F6A766yzmtz3vvvum29F9Q/+8eA3v/nNIvs98MADjY/vWtwfHJanoijyu9/9bpH7r7rqqsbP+cM1z3v9xBNPNP4xYWF+/etfL9CnOQ0bNqzxPuyf/exnpdyOsDS6du2a5P3F5gBoGqEbgDbjxBNPTPJ+cN17770bH6W0MP/73/+WeZzx48fn/PPPX2D7nDlzcuihhzauzH3EEUfMt3/e2dGZM2fmz3/+8wL9586dm2984xul/sFgnssvv7zxXu4f/OAHOfXUUxe5onjy/krnZ5xxRnbcccf5fq6bb755Nt100yTJr371q9x+++0L9J0+fXoOP/zwJO8vxPXhn0tLOvPMM/P0008vsP0///lPfvzjHyd5f2G+L3zhC/PtP+KIIxoXFTvssMNSV1e3wDFGjRqVK6+8Msn7P6clrXq/LHr27JkjjzwySXL//ffnmGOOWeyVC1OnTp3vDwHNbd4ihs8//3xpYwC0N+7pBqDN2GOPPXLIIYfkyiuvzP3335911103Rx55ZLbZZpv06NEjr732WsaOHZtrrrkmn/70p3PVVVct0zibbrppTjzxxIwfPz5f//rX06dPnzz77LO58MILG8967rHHHvnc5z43X7+vfOUrOeWUUzJr1qwcdNBBGT9+fHbeeefU1tbmiSeeyCWXXJJx48Zlm222Kf1S4X79+uWmm27K5z73uUydOjVnnnlmfv/732fffffNNttskz59+mT27NmZNGlS7rjjjvz1r3/Nq6++utBj/epXv8oWW2yR2bNnZ7fddstRRx2VPfbYIyuuuGL+/e9/55xzzml8dNrxxx9fymXWy2LttdfOq6++mi233DInnnhihg4dmuT956ufc845jX9cuOSSSxa4HH6DDTbIcccdl/PPPz+PPvpoNt5445x44okZMmRIZsyYkRtvvDE/+9nPMnfu3HTu3DmXX355ae/jhz/8Ye6+++489NBDufjii3PXXXfl0EMPzUYbbZQVV1wxb775Zp544oncdtttueWWW7LBBhvkG9/4Rim1bL311rnzzjvz8MMP55xzzslnP/vZxufBr7DCClljjTWW6njPPfdc7rvvvvm2vfPOO43/++F/w7vuumvjKvkAbUYBQLt05513FkmKJMVpp51WVf8777xzie0POOCAIkkxcODAhe5vai3bb799kaTYfvvtF7p/zpw5xZFHHllUKpXGYy7s64ADDliqGkaOHNm4/5FHHimGDBmyyGNvs802RV1d3ULr+81vflN06NBhkX332Wef4rbbblvsz3ZJP8ulMXHixGL33Xdf7M9q3teKK65YnH766cV77723wHFuvfXWokePHovtP2LEiGLu3LkLrWPgwIGL/Fw+qCnz5MUXX2xsN3LkyAX2f3AO3XTTTUW3bt0WWm+HDh2KCy64YJHjzJ07t/jWt7612PdcW1tb3HrrrQvtvzT/hpb0vuvq6oovfelLTfocd9hhhwX6N3VOffDfwYsvvrjA/v/9739Fr169Fjruov7NNnW8pnw15XcRQGvj8nIA2pSOHTvmkksuydixY3PYYYflE5/4RFZcccXU1NSkX79+GT58eC688MJccMEFyzzGyiuvnPvvvz9nn312Ntpoo3Tv3j0rrbRSNttss1xyySW5++67071794X2Peigg3Lvvfdmzz33zKqrrpqampqsttpq2XXXXXPNNdfk6quvXq6PYBs4cGBuuummjBkzJieeeGI233zzrLbaauncuXNWWmmlrLXWWvnyl7+cK664IpMnT85pp52WLl26LHCc4cOH57nnnsspp5ySjTbaKD169EiXLl0yYMCA7Lfffrn33nvz85//fL7nPLcGu+++e8aOHZuDDjooAwcOTOfOndOnT5/stddeue+++3Lcccctsm+HDh1y6aWX5p577sl+++2XAQMGpEuXLunRo0c22mijnHLKKXn22WczfPjw0t9H9+7d89e//jX33ntvvvGNb+STn/xkunfvnk6dOqVXr17ZbLPNMmLEiPzjH//I6NGjS6tjjTXWyJgxY3LIIYdk7bXXbrzHG4BFqxRFC6/IAQCtwFVXXZWDDjooyfurXg8aNKhlC2KZDR06NHfffXe233773HXXXS1dDgAfca3rz9EAAADQjgjdAAAAUBKhGwAAAEoidAMAAEBJhG4AAAAoidXLAQAAoCTOdAMAAEBJhG4AAAAoidANAAAAJRG6AQAAoCRCNwAAAJRE6AYAAICSCN0AAABQEqEbAAAASiJ0AwAAQEn+f6NM8IP44DwSAAAAAElFTkSuQmCC", "text/plain": [ "
" ] @@ -242,7 +242,7 @@ "source": [ "# Perform PCA with separate strands\n", "pca = PCA(backend=\"sklearn\", n_components=2)\n", - "components = pca.fit_transform(snpobj, strands=\"separate\")\n", + "components = pca.fit_transform(snpobj, average_strands=False)\n", "print(\"Shape of PCA components with separate strands:\", components.shape)\n", "\n", "# Create DataFrame and plot\n", @@ -548,7 +548,7 @@ "source": [ "# Perform PCA with separate strands and a subset of SNPs\n", "pca = PCA(backend=\"sklearn\", n_components=2)\n", - "components = pca.fit_transform(snpobj, strands=\"separate\", snps_subset=200)\n", + "components = pca.fit_transform(snpobj, average_strands=False, snps_subset=200)\n", "print(\"Shape of PCA components with separate strands and subset of SNPs:\", components.shape)\n", "\n", "# Create DataFrame and plot\n", diff --git a/demos/TorchPCA.ipynb b/demos/TorchPCA.ipynb index b9fc33e..f1070c7 100644 --- a/demos/TorchPCA.ipynb +++ b/demos/TorchPCA.ipynb @@ -12,7 +12,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 7, "id": "2fd07cb3", "metadata": { "scrolled": true @@ -48,7 +48,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 8, "id": "abc98c7c", "metadata": { "scrolled": true @@ -95,7 +95,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 9, "id": "fa643a80", "metadata": {}, "outputs": [ @@ -137,7 +137,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 10, "id": "dfd07a81", "metadata": {}, "outputs": [ @@ -188,7 +188,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 11, "id": "0e1a6754", "metadata": {}, "outputs": [ @@ -197,7 +197,7 @@ "output_type": "stream", "text": [ "Using device: cpu\n", - "PCA completed. Data shape: torch.Size([4, 976599]), Time taken: 0.108 seconds\n", + "PCA completed. Data shape: torch.Size([4, 976599]), Time taken: 0.078 seconds\n", "PCA result shape: torch.Size([4, 2])\n" ] } @@ -233,7 +233,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 12, "id": "65b4a232", "metadata": { "scrolled": true diff --git a/snputils/processing/pca.py b/snputils/processing/pca.py index 4d3603b..ac0f9e2 100644 --- a/snputils/processing/pca.py +++ b/snputils/processing/pca.py @@ -294,10 +294,14 @@ def fit_transform(self, X): class PCA: """ - A class to perform Principal Component Analysis (PCA) on SNP data using the `SNPObject` and - either `sklearn.decomposition.PCA` or a `TorchPCA` implementation, allowing for efficient GPU-based PCA. + A class for Principal Component Analysis (PCA) on SNP data stored in a `snputils.snp.genobj.SNPObject`. + This class employs either + [sklearn.decomposition.PCA](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html) + or the custom `TorchPCA` implementation for efficient GPU-accelerated analysis. - Supports both separate and average strand handling. + The PCA class supports both separate and averaged strand processing for SNP data. If the `snpobj` parameter is + provided during instantiation, the `fit_transform` method will be automatically called, + applying PCA to transform the data according to the selected configuration. """ def __init__( self, @@ -313,17 +317,18 @@ def __init__( """ Args: snpobj (SNPObject, optional): - A SNPObject object instance. + A SNPObject instance. backend (str, default='pytorch'): - The backend to use ('sklearn' or 'pytorch'). Defaults to 'sklearn'. + The backend to use (`'sklearn'` or `'pytorch'`). Default is 'pytorch'. n_components (int, default=2): - The number of principal components. Defaults to 2. + The number of principal components. Default is 2. fitting (str, default='full'): The fitting approach to use for the SVD computation (only for `backend='pytorch'`). - - 'full': Full Singular Value Decomposition (SVD). - - 'lowrank': Low-rank approximation, which provides a faster but approximate solution. + - `'full'`: Full Singular Value Decomposition (SVD). + - `'lowrank'`: Low-rank approximation, which provides a faster but approximate solution. + Default is 'full'. device (str, default='cpu'): - Device to use ('CPU', 'GPU', 'cuda', or 'cuda:'). Defaults to 'cpu'. + Device to use (`'cpu'`, `'gpu'`, `'cuda'`, or `'cuda:'`). Default is 'cpu'. average_strands (bool, default=True): True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. samples_subset (int or list of int, optional): @@ -341,6 +346,9 @@ def __init__( self.__snps_subset = snps_subset self.__X_ = None self.__X_new_ = None # Store transformed SNP data + self.__n_components_ = None + self.__components_ = None + self.__mean_ = None # Initialize PCA backend if self.backend == "pytorch": @@ -354,33 +362,13 @@ def __init__( if self.snpobj is not None: self.fit_transform(snpobj) - def __getitem__(self, key): - """ - To access an attribute of the class using the square bracket notation, - similar to a dictionary. - """ - try: - return getattr(self, key) - except AttributeError: - raise KeyError(f'Invalid key: {key}') - - def __setitem__(self, key, value): - """ - To set an attribute of the class using the square bracket notation, - similar to a dictionary. - """ - try: - setattr(self, key, value) - except AttributeError: - raise KeyError(f'Invalid key: {key}') - @property def snpobj(self) -> Optional['SNPObject']: """ Retrieve `snpobj`. Returns: - SNPObject: A SNPObject object instance. + **SNPObject:** A SNPObject instance. """ return self.__snpobj @@ -397,7 +385,7 @@ def backend(self) -> str: Retrieve `backend`. Returns: - str: The backend to use ('sklearn' or 'pytorch'). + **str:** The backend to use (`'sklearn'` or `'pytorch'`). """ return self.__backend @@ -414,7 +402,7 @@ def n_components(self) -> int: Retrieve `n_components`. Returns: - int: The number of principal components. + **int:** The number of principal components. """ return self.__n_components @@ -431,7 +419,10 @@ def fitting(self) -> str: Retrieve `fitting`. Returns: - str: The fitting approach to use for the SVD computation. + **str:** + The fitting approach to use for the SVD computation (only for `backend='pytorch'`). + - `'full'`: Full Singular Value Decomposition (SVD). + - `'lowrank'`: Low-rank approximation, which provides a faster but approximate solution. """ return self.__fitting @@ -448,7 +439,7 @@ def device(self) -> torch.device: Retrieve `device`. Returns: - torch.device: Device to use ('CPU', 'GPU', 'cuda', or 'cuda:'). + **torch.device:** Device to use (`'cpu'`, `'gpu'`, `'cuda'`, or `'cuda:'`). """ return self.__device @@ -465,7 +456,8 @@ def average_strands(self) -> bool: Retrieve `average_strands`. Returns: - bool: True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. + **bool:** + True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. """ return self.__average_strands @@ -477,53 +469,108 @@ def average_strands(self, x: bool) -> None: self.__average_strands = x @property - def samples_subset(self) -> Optional[Union[int, List]]: + def samples_subset(self) -> Optional[Union[int, List[int]]]: """ Retrieve `samples_subset`. Returns: - int or list of int: Subset of samples to include, as an integer for the first samples or a list of sample indices. + **int or list of int:** + Subset of samples to include, as an integer for the first samples or a list of sample indices. """ return self.__samples_subset @samples_subset.setter - def samples_subset(self, x: Optional[Union[int, List]]) -> None: + def samples_subset(self, x: Optional[Union[int, List[int]]]) -> None: """ Update `samples_subset`. """ self.__samples_subset = x @property - def snps_subset(self) -> Optional[Union[int, List]]: + def snps_subset(self) -> Optional[Union[int, List[int]]]: """ Retrieve `snps_subset`. Returns: - int or list of int: Subset of SNPs to include, as an integer for the first SNPs or a list of SNP indices. + **int or list of int:** Subset of SNPs to include, as an integer for the first SNPs or a list of SNP indices. """ return self.__snps_subset @snps_subset.setter - def snps_subset(self, x: Optional[Union[int, List]]) -> None: + def snps_subset(self, x: Optional[Union[int, List[int]]]) -> None: """ Update `snps_subset`. """ self.__snps_subset = x + @property + def n_components_(self) -> Optional[int]: + """ + Retrieve `n_components_`. + + Returns: + **int:** + The effective number of components retained after fitting, + calculated as `min(self.n_components, min(n_samples, n_snps))`. + """ + return self.__n_components_ + + @n_components_.setter + def n_components_(self, x: int) -> None: + """ + Update `n_components_`. + """ + self.__n_components_ = x + + @property + def components_(self) -> Optional[Union[torch.Tensor, np.ndarray]]: + """ + Retrieve `components_`. + + Returns: + **tensor or array of shape (n_components_, n_snps):** + Matrix of principal components, where each row is a principal component vector. + """ + return self.__components_ + + @components_.setter + def components_(self, x: Union[torch.Tensor, np.ndarray]) -> None: + """ + Update `components_`. + """ + self.__components_ = x + + @property + def mean_(self) -> Optional[Union[torch.Tensor, np.ndarray]]: + """ + Retrieve `mean_`. + + Returns: + **tensor or array of shape (n_snps,):** + Per-feature mean vector of the input data used for centering. + """ + return self.__mean_ + + @mean_.setter + def mean_(self, x: Union[torch.Tensor, np.ndarray]) -> None: + """ + Update `mean_`. + """ + self.__mean_ = x + @property def X_(self) -> Optional[Union[torch.Tensor, np.ndarray]]: """ Retrieve `X_`. Returns: - torch.Tensor or numpy.ndarray of shape (n_samples, n_snps): - The input SNP data used for fitting the model, where `n_samples` is the number of - samples and `n_snps` is the number of features. + **tensor or array of shape (n_samples, n_snps):** + The SNP data matrix used to fit the model. """ return self.__X_ @X_.setter - def X_(self, x: torch.Tensor) -> None: + def X_(self, x: Union[torch.Tensor, np.ndarray]) -> None: """ Update `X_`. """ @@ -535,12 +582,13 @@ def X_new_(self) -> Optional[Union[torch.Tensor, np.ndarray]]: Retrieve `X_new_`. Returns: - torch.Tensor or numpy.ndarray: The transformed SNP data onto the `n_components` principal components. + **tensor or array of shape (n_samples, n_components_):** + The transformed SNP data projected onto the `n_components_` principal components. """ return self.__X_new_ @X_new_.setter - def X_new_(self, x: torch.Tensor) -> None: + def X_new_(self, x: Union[torch.Tensor, np.ndarray]) -> None: """ Update `X_new_`. """ @@ -548,11 +596,11 @@ def X_new_(self, x: torch.Tensor) -> None: def copy(self) -> 'PCA': """ - Create and return a copy of the current `PCA` instance. + Create and return a copy of `self`. Returns: - PCA: - A new instance of the current object. + **PCA:** + A new instance of the current object. """ return copy.copy(self) @@ -660,18 +708,18 @@ def _get_data_from_snpobj( return X def fit( - self, - snpobj: Optional['SNPObject'] = None, - average_strands: Optional[bool] = None, - samples_subset: Optional[Union[int, List]] = None, - snps_subset: Optional[Union[int, List]] = None - ) -> 'PCA': + self, + snpobj: Optional['SNPObject'] = None, + average_strands: Optional[bool] = None, + samples_subset: Optional[Union[int, List]] = None, + snps_subset: Optional[Union[int, List]] = None + ) -> 'PCA': """ - Fit the model with the SNP data in `snpobj`. + Fit the model to the input SNP data stored in the provided `snpobj`. Args: snpobj (SNPObject, optional): - A SNPObject object instance. If None, defaults to `self.snpobj`. + A SNPObject instance. If None, defaults to `self.snpobj`. average_strands (bool, optional): True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. If None, defaults to `self.average_strands`. @@ -683,25 +731,32 @@ def fit( If None, defaults to `self.snps_subset`. Returns: - PCA: The fitted PCA object instance. + **PCA:** + The fitted instance of `self`. """ self.X_ = self._get_data_from_snpobj(snpobj, average_strands, samples_subset, snps_subset) self.pca.fit(self.X_) + + # Update attributes based on the fitted model + self.n_components_ = self.pca.n_components_ + self.components_ = self.pca.components_ + self.mean_ = self.pca.mean_ + return self - + def transform( - self, - snpobj: Optional['SNPObject'] = None, - average_strands: Optional[bool] = None, - samples_subset: Optional[Union[int, List]] = None, - snps_subset: Optional[Union[int, List]] = None - ): + self, + snpobj: Optional['SNPObject'] = None, + average_strands: Optional[bool] = None, + samples_subset: Optional[Union[int, List]] = None, + snps_subset: Optional[Union[int, List]] = None + ): """ - Apply dimensionality reduction on SNP data in `snpobj`. + Apply dimensionality reduction to the input SNP data stored in the provided `snpobj` using the fitted model. Args: snpobj (SNPObject, optional): - A SNPObject object instance. If None, defaults to `self.snpobj`. + A SNPObject instance. If None, defaults to `self.snpobj`. average_strands (bool, optional): True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. If None, defaults to `self.average_strands`. @@ -713,26 +768,26 @@ def transform( If None, defaults to `self.snps_subset`. Returns: - torch.Tensor or numpy.ndarray: The transformed SNP data onto the `n_components` principal components. + **tensor or array of shape (n_samples, n_components):** + The transformed SNP data projected onto the `n_components_` principal components, + stored in `self.X_new_`. """ - if self.X_ is None: + # Retrieve or update the data to transform + if snpobj is not None or self.X_ is None: self.X_ = self._get_data_from_snpobj(snpobj, average_strands, samples_subset, snps_subset) - self.X_new_ = self.pca.transform(self.X_) - return self.X_new_ - - def fit_transform( - self, - snpobj: Optional['SNPObject'] = None, - average_strands: Optional[bool] = None, - samples_subset: Optional[Union[int, List]] = None, - snps_subset: Optional[Union[int, List]] = None - ): + + # Apply transformation using the fitted PCA model + return self.pca.transform(self.X_) + + def fit_transform(self, snpobj: Optional['SNPObject'] = None, average_strands: Optional[bool] = None, + samples_subset: Optional[Union[int, List]] = None, snps_subset: Optional[Union[int, List]] = None): """ - Fit the model with SNP data in `snpobj` and apply the dimensionality reduction on SNP data in `snpobj`. + Fit the model to the SNP data stored in the provided `snpobj` and apply the dimensionality reduction + on the same SNP data. Args: snpobj (SNPObject, optional): - A SNPObject object instance. If None, defaults to `self.snpobj`. + A SNPObject instance. If None, defaults to `self.snpobj`. average_strands (bool, optional): True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. If None, defaults to `self.average_strands`. @@ -744,9 +799,16 @@ def fit_transform( If None, defaults to `self.snps_subset`. Returns: - torch.Tensor or numpy.ndarray: The transformed SNP data onto the `n_components` principal components, - stored in `self.X_new_`. + **tensor or array of shape (n_samples, n_components):** + The transformed SNP data projected onto the `n_components_` principal components, + stored in `self.X_new_`. """ self.X_ = self._get_data_from_snpobj(snpobj, average_strands, samples_subset, snps_subset) self.X_new_ = self.pca.fit_transform(self.X_) + + # Update attributes based on the fitted model + self.n_components_ = self.pca.n_components_ + self.components_ = self.pca.components_ + self.mean_ = self.pca.mean_ + return self.X_new_ From 7ef88e203da01b93a7b3b447b7b2cbe03799f1f9 Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 10:47:23 -0500 Subject: [PATCH 08/11] Refine docstrings for mdPCA. Remove unused parameters and attributes --- snputils/processing/mdpca.py | 261 ++++++++++++++++------------------- 1 file changed, 122 insertions(+), 139 deletions(-) diff --git a/snputils/processing/mdpca.py b/snputils/processing/mdpca.py index 87b479f..484ecb7 100644 --- a/snputils/processing/mdpca.py +++ b/snputils/processing/mdpca.py @@ -1,15 +1,13 @@ +import pathlib import os import time import gc import logging import logging.config import numpy as np -import pandas as pd import copy -from typing import Optional +from typing import Optional, Dict, List, Union from sklearn.decomposition import TruncatedSVD -import plotly_express as px -import plotly from snputils.snp.genobj.snpobj import SNPObject from snputils.ancestry.genobj.local import LocalAncestryObject @@ -19,9 +17,10 @@ class mdPCA: """ - A class for performing missing data principal component analysis (mdPCA). + A class for missing data principal component analysis (mdPCA). - If `snpobj`, `laiobj`, `labels_file`, and `ancestry` parameters are all provided during instantiation, + This class supports both separate and averaged strand processing for SNP data. If the `snpobj`, + `laiobj`, `labels_file`, and `ancestry` parameters are all provided during instantiation, the `fit_transform` method will be automatically called, applying the specified mdPCA method to transform the data upon instantiation. """ @@ -36,13 +35,12 @@ def __init__( prob_thresh: float = 0, average_strands: bool = False, is_weighted: bool = False, - groups_to_remove: dict = {}, + groups_to_remove: Dict[int, List[str]] = {}, min_percent_snps: float = 4, save_masks: bool = False, load_masks: bool = False, - masks_file: str = 'masks.npz', - output_file: str = 'output.tsv', - scatterplot_file: str = 'scatter_plot.html', + masks_file: Union[str, pathlib.Path] = 'masks.npz', + output_file: Union[str, pathlib.Path] = 'output.tsv', covariance_matrix_file: Optional[str] = None, n_components: int = 2, rsid_or_chrompos: int = 2, @@ -52,35 +50,34 @@ def __init__( Args: method (str, default='weighted_cov_pca'): The PCA method to use for dimensionality reduction. Options include: - - 'weighted_cov_pca': + - `'weighted_cov_pca'`: Simple covariance-based PCA, weighted by sample strengths. - - 'regularized_optimization_ils': + - `'regularized_optimization_ils'`: Regularized optimization followed by iterative, weighted (via the strengths) least squares projection of missing samples using the original covariance matrix (considering only relevant elements not missing in the original covariance matrix for those samples). - - 'cov_matrix_imputation': + - `'cov_matrix_imputation'`: Eigen-decomposition of the covariance matrix after first imputing the covariance matrix missing values using the Iterative SVD imputation method. - - 'cov_matrix_imputation_ils': + - `'cov_matrix_imputation_ils'`: The method of 'cov_matrix_imputation', but where afterwards missing samples are re-projected onto the space given by 'cov_matrix_imputation' using the same iterative method on the original covariance matrix just as done in 'regularized_optimization_ils'. - - 'nonmissing_pca_ils': + - `'nonmissing_pca_ils'`: The method of 'weighted_cov_pca' on the non-missing samples, followed by the projection of missing samples onto the space given by 'weighted_cov_pca' using the same iterative method on the original covariance matrix just as done in 'regularized_optimization_ils'. snpobj (SNPObject, optional): - A SNPObject object instance. + A SNPObject instance. laiobj (LAIObject, optional): A LAIObject instance. labels_file (str, optional): - Path to the labels file. It should be a `.tsv` file where the first column has header `indID` - and contains the individual identifiers, and the second column has header `label` and contains - the groups for all individuals. If `is_weighted=True`, a `weight` column with individual weights is required. - Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be combined - into groups, with respective weights. + Path to the labels file in .tsv format. The first column, `indID`, contains the individual identifiers, and the second + column, `label`, specifies the groups for all individuals. If `is_weighted=True`, a `weight` column with individual + weights is required. Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be + combined into groups, with respective weights. ancestry (str, optional): - Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at 0. + Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at `0`. is_masked (bool, default=True): True if an ancestry file is passed for ancestry-specific masking, or False otherwise. prob_thresh (float, default=0): @@ -89,23 +86,21 @@ def __init__( True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. is_weighted (bool, default=False): True if weights are provided in the labels file, or False otherwise. - groups_to_remove (dict, default={}): + groups_to_remove (dict of int to list of str, default={}): Dictionary specifying groups to exclude from analysis. Keys are array numbers, and values are lists of groups to remove for each array. Example: `{1: ['group1', 'group2'], 2: [], 3: ['group3']}`. min_percent_snps (float, default=4): - Minimum percentage of SNPs to be known in an individual for an individual to be included in the analysis. + Minimum percentage of SNPs that must be known for an individual to be included in the analysis. All individuals with fewer percent of unmasked SNPs than this threshold will be excluded. save_masks (bool, default=False): True if the masked matrices are to be saved in a `.npz` file, or False otherwise. load_masks (bool, default=False): - True if the masked matrices are to be loaded from pre-existing `.npz` file (`masks_file`), or False otherwise. - masks_file (str, default='masks.npz'): + True if the masked matrices are to be loaded from a pre-existing `.npz` file specified by `masks_file`, or False otherwise. + masks_file (str or pathlib.Path, default='masks.npz'): Path to the `.npz` file used for saving/loading masked matrices. - output_file (str, default='output.tsv'): + output_file (str or pathlib.Path, default='output.tsv'): Path to the output `.tsv` file where mdPCA results are saved. - scatterplot_file (str, default='scatter_plot.html'): - Path to save the mdPCA scatter plot in HTML format. covariance_matrix_file (str, optional): Path to save the covariance matrix file in `.npy` format. If None, the covariance matrix is not saved. Default is None. n_components (int, default=2): @@ -113,7 +108,7 @@ def __init__( rsid_or_chrompos (int, default=2): Format indicator for SNP IDs in the SNP data. Use 1 for `rsID` format or 2 for `chromosome_position`. percent_vals_masked (float, default=0): - Percentage of values in the covariance matrix to be masked and then imputed, if `method` is + Percentage of values in the covariance matrix to be masked and then imputed. Only applicable if `method` is `'cov_matrix_imputation'` or `'cov_matrix_imputation_ils'`. """ self.__snpobj = snpobj @@ -131,7 +126,6 @@ def __init__( self.__load_masks = load_masks self.__masks_file = masks_file self.__output_file = output_file - self.__scatterplot_file = scatterplot_file self.__covariance_matrix_file = covariance_matrix_file self.__n_components = n_components self.__rsid_or_chrompos = rsid_or_chrompos @@ -162,22 +156,39 @@ def __setitem__(self, key, value): except AttributeError: raise KeyError(f'Invalid key: {key}') + @property + def method(self) -> str: + """ + Retrieve `method`. + + Returns: + **str:** The PCA method to use for dimensionality reduction. + """ + return self.__method + + @method.setter + def method(self, x: str) -> None: + """ + Update `method`. + """ + self.__method = x + @property def snpobj(self) -> Optional['SNPObject']: """ Retrieve `snpobj`. Returns: - SNPObject: A SNPObject object instance. + **SNPObject:** A SNPObject instance. """ return self.__snpobj @snpobj.setter - def snpobj(self, value: 'SNPObject') -> None: + def snpobj(self, x: 'SNPObject') -> None: """ Update `snpobj`. """ - self.__snpobj = value + self.__snpobj = x @property def laiobj(self) -> Optional['LocalAncestryObject']: @@ -185,16 +196,16 @@ def laiobj(self) -> Optional['LocalAncestryObject']: Retrieve `laiobj`. Returns: - LocalAncestryObject: A LocalAncestryObject instance. + **LocalAncestryObject:** A LocalAncestryObject instance. """ return self.__laiobj @laiobj.setter - def laiobj(self, value: 'LocalAncestryObject') -> None: + def laiobj(self, x: 'LocalAncestryObject') -> None: """ Update `laiobj`. """ - self.__laiobj = value + self.__laiobj = x @property def labels_file(self) -> Optional[str]: @@ -202,16 +213,16 @@ def labels_file(self) -> Optional[str]: Retrieve `labels_file`. Returns: - str: Path to the labels file in `.tsv` format. + **str:** Path to the labels file in `.tsv` format. """ return self.__labels_file @labels_file.setter - def labels_file(self, value: str) -> None: + def labels_file(self, x: str) -> None: """ Update `labels_file`. """ - self.__labels_file = value + self.__labels_file = x @property def ancestry(self) -> Optional[str]: @@ -219,33 +230,16 @@ def ancestry(self) -> Optional[str]: Retrieve `ancestry`. Returns: - str: Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at 0. + **str:** Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at 0. """ return self.__ancestry @ancestry.setter - def ancestry(self, value: str) -> None: + def ancestry(self, x: str) -> None: """ Update `ancestry`. """ - self.__ancestry = value - - @property - def method(self) -> str: - """ - Retrieve `method`. - - Returns: - str: The PCA method to use for dimensionality reduction. - """ - return self.__method - - @method.setter - def method(self, value: str) -> None: - """ - Update `method`. - """ - self.__method = value + self.__ancestry = x @property def is_masked(self) -> bool: @@ -253,16 +247,16 @@ def is_masked(self) -> bool: Retrieve `is_masked`. Returns: - bool: True if an ancestry file is passed for ancestry-specific masking, or False otherwise. + **bool:** True if an ancestry file is passed for ancestry-specific masking, or False otherwise. """ return self.__is_masked @is_masked.setter - def is_masked(self, value: bool) -> None: + def is_masked(self, x: bool) -> None: """ Update `is_masked`. """ - self.__is_masked = value + self.__is_masked = x @property def prob_thresh(self) -> float: @@ -270,15 +264,15 @@ def prob_thresh(self) -> float: Retrieve `prob_thresh`. Returns: - float: Minimum probability threshold for a SNP to belong to an ancestry. + **float:** Minimum probability threshold for a SNP to belong to an ancestry. """ return self.__prob_thresh @prob_thresh.setter - def prob_thresh(self, value: float) -> None: + def prob_thresh(self, x: float) -> None: """Update `prob_thresh`. """ - self.__prob_thresh = value + self.__prob_thresh = x @property def average_strands(self) -> bool: @@ -286,16 +280,16 @@ def average_strands(self) -> bool: Retrieve `average_strands`. Returns: - bool: True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. + **bool:** True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. """ return self.__average_strands @average_strands.setter - def average_strands(self, value: bool) -> None: + def average_strands(self, x: bool) -> None: """ Update `average_strands`. """ - self.__average_strands = value + self.__average_strands = x @property def is_weighted(self) -> bool: @@ -303,33 +297,34 @@ def is_weighted(self) -> bool: Retrieve `is_weighted`. Returns: - bool: True if weights are provided in the labels file, or False otherwise. + **bool:** True if weights are provided in the labels file, or False otherwise. """ return self.__is_weighted @is_weighted.setter - def is_weighted(self, value: bool) -> None: + def is_weighted(self, x: bool) -> None: """ Update `is_weighted`. """ - self.__is_weighted = value + self.__is_weighted = x @property - def groups_to_remove(self) -> dict: + def groups_to_remove(self) -> Dict[int, List[str]]: """ Retrieve `groups_to_remove`. Returns: - dict: Dictionary specifying groups to exclude from analysis. Keys are array numbers, and values are + **dict of int to list of str:** Dictionary specifying groups to exclude from analysis. Keys are array numbers, and values are lists of groups to remove for each array. Example: `{1: ['group1', 'group2'], 2: [], 3: ['group3']}`. """ return self.__groups_to_remove @groups_to_remove.setter - def groups_to_remove(self, value: dict) -> None: - """Update `groups_to_remove`. + def groups_to_remove(self, x: Dict[int, List[str]]) -> None: + """ + Update `groups_to_remove`. """ - self.__groups_to_remove = value + self.__groups_to_remove = x @property def min_percent_snps(self) -> float: @@ -337,18 +332,18 @@ def min_percent_snps(self) -> float: Retrieve `min_percent_snps`. Returns: - float: - Minimum percentage of SNPs to be known in an individual for an individual to be included in the analysis. + **float:** + Minimum percentage of SNPs that must be known for an individual to be included in the analysis. All individuals with fewer percent of unmasked SNPs than this threshold will be excluded. """ return self.__min_percent_snps @min_percent_snps.setter - def min_percent_snps(self, value: float) -> None: + def min_percent_snps(self, x: float) -> None: """ Update `min_percent_snps`. """ - self.__min_percent_snps = value + self.__min_percent_snps = x @property def save_masks(self) -> bool: @@ -356,16 +351,16 @@ def save_masks(self) -> bool: Retrieve `save_masks`. Returns: - bool: True if the masked matrices are to be saved in a `.npz` file, or False otherwise. + **bool:** True if the masked matrices are to be saved in a `.npz` file, or False otherwise. """ return self.__save_masks @save_masks.setter - def save_masks(self, value: bool) -> None: + def save_masks(self, x: bool) -> None: """ Update `save_masks`. """ - self.__save_masks = value + self.__save_masks = x @property def load_masks(self) -> bool: @@ -373,67 +368,52 @@ def load_masks(self) -> bool: Retrieve `load_masks`. Returns: - bool: True if the masked matrices are to be loaded from pre-existing `.npz` file (`masks_file`), or False otherwise. + **bool:** + True if the masked matrices are to be loaded from a pre-existing `.npz` file specified + by `masks_file`, or False otherwise. """ return self.__load_masks @load_masks.setter - def load_masks(self, value: bool) -> None: + def load_masks(self, x: bool) -> None: """ Update `load_masks`. """ - self.__load_masks = value + self.__load_masks = x @property - def masks_file(self) -> str: + def masks_file(self) -> Union[str, pathlib.Path]: """ Retrieve `masks_file`. Returns: - str: Path to the `.npz` file used for saving/loading masked matrices. + **str or pathlib.Path:** Path to the `.npz` file used for saving/loading masked matrices. """ return self.__masks_file @masks_file.setter - def masks_file(self, value: str) -> None: + def masks_file(self, x: Union[str, pathlib.Path]) -> None: """ Update `masks_file`. """ - self.__masks_file = value + self.__masks_file = x @property - def output_file(self) -> str: + def output_file(self) -> Union[str, pathlib.Path]: """ Retrieve `output_file`. Returns: - str: Path to the output `.tsv` file where mdPCA results are saved. + **str or pathlib.Path:** Path to the output `.tsv` file where mdPCA results are saved. """ return self.__output_file @output_file.setter - def output_file(self, value: str) -> None: + def output_file(self, x: Union[str, pathlib.Path]) -> None: """ Update `output_file`. """ - self.__output_file = value - - @property - def scatterplot_file(self) -> str: - """ - Retrieve `scatterplot_file`. - - Returns: - str: Path to save the mdPCA scatter plot in HTML format. - """ - return self.__scatterplot_file - - @scatterplot_file.setter - def scatterplot_file(self, value: str) -> None: - """ - Update `scatterplot_file`. - """ - self.__scatterplot_file = value + self.__output_file = x @property def covariance_matrix_file(self) -> Optional[str]: @@ -441,16 +421,16 @@ def covariance_matrix_file(self) -> Optional[str]: Retrieve `covariance_matrix_file`. Returns: - Optional[str]: Path to save the covariance matrix file in `.npy` format. + **str:** Path to save the covariance matrix file in `.npy` format. """ return self.__covariance_matrix_file @covariance_matrix_file.setter - def covariance_matrix_file(self, value: Optional[str]) -> None: + def covariance_matrix_file(self, x: Optional[str]) -> None: """ Update `covariance_matrix_file`. """ - self.__covariance_matrix_file = value + self.__covariance_matrix_file = x @property def n_components(self) -> int: @@ -458,16 +438,16 @@ def n_components(self) -> int: Retrieve `n_components`. Returns: - int: The number of principal components. + **int:** The number of principal components. """ return self.__n_components @n_components.setter - def n_components(self, value: int) -> None: + def n_components(self, x: int) -> None: """ Update `n_components`. """ - self.__n_components = value + self.__n_components = x @property def rsid_or_chrompos(self) -> int: @@ -475,16 +455,16 @@ def rsid_or_chrompos(self) -> int: Retrieve `rsid_or_chrompos`. Returns: - int: Format indicator for SNP IDs in the SNP data. Use 1 for `rsID` format or 2 for `chromosome_position`. + **int:** Format indicator for SNP IDs in the SNP data. Use 1 for `rsID` format or 2 for `chromosome_position`. """ return self.__rsid_or_chrompos @rsid_or_chrompos.setter - def rsid_or_chrompos(self, value: int) -> None: + def rsid_or_chrompos(self, x: int) -> None: """ Update `rsid_or_chrompos`. """ - self.__rsid_or_chrompos = value + self.__rsid_or_chrompos = x @property def percent_vals_masked(self) -> float: @@ -492,16 +472,18 @@ def percent_vals_masked(self) -> float: Retrieve `percent_vals_masked`. Returns: - float: Percentage of values in the covariance matrix to be masked and then imputed. + **float:** + Percentage of values in the covariance matrix to be masked and then imputed. Only applicable if `method` is + `'cov_matrix_imputation'` or `'cov_matrix_imputation_ils'`. """ return self.__percent_vals_masked @percent_vals_masked.setter - def percent_vals_masked(self, value: float) -> None: + def percent_vals_masked(self, x: float) -> None: """ Update `percent_vals_masked`. """ - self.__percent_vals_masked = value + self.__percent_vals_masked = x @property def X_new_(self) -> Optional[np.ndarray]: @@ -509,7 +491,8 @@ def X_new_(self) -> Optional[np.ndarray]: Retrieve `X_new_`. Returns: - numpy.ndarray: The transformed SNP data onto the `n_components` principal components. + **array of shape (n_samples, n_components):** + The transformed SNP data projected onto the `n_components` principal components. """ return self.__X_new_ @@ -522,11 +505,11 @@ def X_new_(self, x: np.ndarray) -> None: def copy(self) -> 'mdPCA': """ - Create and return a copy of the current `mdPCA` instance. + Create and return a copy of `self`. Returns: - mdPCA: - A new instance of the current object. + **mdPCA:** + A new instance of the current object. """ return copy.copy(self) @@ -864,7 +847,7 @@ def fit_transform( average_strands: Optional[bool] = None ) -> np.ndarray: """ - Fit and transform SNP data in `snpobj` into a lower-dimensional space. + Fit the model to the SNP data stored in the provided `snpobj` and apply the dimensionality reduction on the same SNP data. This method starts by loading or updating SNP and ancestry data. Then, it manages missing values by applying masks based on ancestry, either by loading a pre-existing mask or generating new ones. After processing these @@ -873,15 +856,14 @@ def fit_transform( Args: snpobj (SNPObject, optional): - A SNPObject object instance. + A SNPObject instance. laiobj (LAIObject, optional): A LAIObject instance. labels_file (str, optional): - Path to the labels file. It should be a `.tsv` file where the first column has header `indID` - and contains the individual identifiers, and the second column has header `label` and contains - the groups for all individuals. If `is_weighted=True`, a `weight` column with individual weights is required. - Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be combined - into groups, with respective weights. + Path to the labels file in .tsv format. The first column, `indID`, contains the individual identifiers, and the second + column, `label`, specifies the groups for all individuals. If `is_weighted=True`, a `weight` column with individual + weights is required. Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be + combined into groups, with respective weights. ancestry (str, optional): Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at 0. average_strands (bool, optional): @@ -889,7 +871,8 @@ def fit_transform( If None, defaults to `self.average_strands`. Returns: - numpy.ndarray: The transformed SNP data onto the `n_components` principal components, stored in `self.X_new_`. + **array of shape (n_samples, n_components):** + The transformed SNP data projected onto the `n_components` principal components, stored in `self.X_new_`. """ if snpobj is None: snpobj = self.snpobj @@ -903,7 +886,7 @@ def fit_transform( average_strands = self.average_strands if self.load_masks: - masks, rs_id_list, ind_id_list, labels, weights = self._load_mask_file() + masks, rs_id_list, ind_id_list, _, weights = self._load_mask_file() else: masks, rs_id_list, ind_id_list = array_process( self.snpobj, @@ -914,7 +897,7 @@ def fit_transform( self.rsid_or_chrompos ) - masks, ind_id_list, labels, weights = process_labels_weights( + masks, ind_id_list, _, weights = process_labels_weights( self.labels_file, masks, rs_id_list, @@ -928,7 +911,7 @@ def fit_transform( self.masks_file ) - X_incomplete, _, ind_IDs = self._process_masks(masks, rs_id_list, ind_id_list) + X_incomplete, _, _ = self._process_masks(masks, rs_id_list, ind_id_list) # Call run_cov_matrix with the specified method self.X_new_ = self._run_cov_matrix( From 641d10b31777529794f4477015058f313a20b38c Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 10:49:59 -0500 Subject: [PATCH 09/11] Refine docstrings for maasMDS. Remove unused parameters --- snputils/processing/maasmds.py | 286 ++++++--------------------------- 1 file changed, 53 insertions(+), 233 deletions(-) diff --git a/snputils/processing/maasmds.py b/snputils/processing/maasmds.py index 8eb2653..e5e2c9d 100644 --- a/snputils/processing/maasmds.py +++ b/snputils/processing/maasmds.py @@ -1,6 +1,7 @@ +import pathlib import numpy as np import copy -from typing import Optional +from typing import Optional, Dict, List, Union from snputils.snp.genobj.snpobj import SNPObject from snputils.ancestry.genobj.local import LocalAncestryObject @@ -10,9 +11,10 @@ class maasMDS: """ - A class for performing multiple array ancestry-specific multidimensional scaling (maasMDS). + A class for multiple array ancestry-specific multidimensional scaling (maasMDS). - If `snpobj`, `laiobj`, `labels_file`, and `ancestry` parameters are all provided during instantiation, + This class supports both separate and averaged strand processing for SNP data. If the `snpobj`, + `laiobj`, `labels_file`, and `ancestry` parameters are all provided during instantiation, the `fit_transform` method will be automatically called, applying the specified maasMDS method to transform the data upon instantiation. """ @@ -26,30 +28,28 @@ def __init__( prob_thresh: float = 0, average_strands: bool = False, is_weighted: bool = False, - groups_to_remove: dict = {}, + groups_to_remove: Dict[int, List[str]] = {}, min_percent_snps: float = 4, save_masks: bool = False, load_masks: bool = False, - masks_file: str = 'masks.npz', + masks_file: Union[str, pathlib.Path] = 'masks.npz', distance_type: str = 'AP', n_components: int = 2, - plot_reg: bool = True, rsid_or_chrompos: int = 2 ): """ Args: snpobj (SNPObject, optional): - A SNPObject object instance. + A SNPObject instance. laiobj (LAIObject, optional): A LAIObject instance. labels_file (str, optional): - Path to the labels file. It should be a `.tsv` file where the first column has header `indID` - and contains the individual identifiers, and the second column has header `label` and contains - the groups for all individuals. If `is_weighted=True`, a `weight` column with individual weights is required. - Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be combined - into groups, with respective weights. + Path to the labels file in .tsv format. The first column, `indID`, contains the individual identifiers, and the second + column, `label`, specifies the groups for all individuals. If `is_weighted=True`, a `weight` column with individual + weights is required. Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be + combined into groups, with respective weights. ancestry (str, optional): - Target ancestry for analysis, such as 'AFR' or 'EUR'. + Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at `0`. is_masked (bool, default=True): True if an ancestry file is passed for ancestry-specific masking, or False otherwise. prob_thresh (float, default=0.0): @@ -58,7 +58,7 @@ def __init__( True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. is_weighted (bool, default=False): True if weights are provided in the labels file, or False otherwise. - groups_to_remove (dict, default={}): + groups_to_remove (dict of int to list of str, default={}): Dictionary specifying groups to exclude from analysis. Keys are array numbers, and values are lists of groups to remove for each array. Example: `{1: ['group1', 'group2'], 2: [], 3: ['group3']}`. @@ -68,16 +68,14 @@ def __init__( save_masks (bool, default=False): True if the masked matrices are to be saved in a `.npz` file, or False otherwise. load_masks (bool, default=False): - True if the masked matrices are to be loaded from pre-existing `.npz` file (`masks_file`), or False otherwise. - masks_file (str, default="mask_files_eas.npz"): + True if the masked matrices are to be loaded from a pre-existing `.npz` file specified by `masks_file`, or False otherwise. + masks_file (str or pathlib.Path, default='masks.npz'): Path to the `.npz` file used for saving/loading masked matrices. distance_type (str, default='AP'): Distance metric to use. Options to choose from are: 'Manhattan', 'RMS' (Root Mean Square), 'AP' (Average Pairwise). If `average_strands=True`, use 'distance_type=AP'. n_components (int, default=2): The number of principal components. - plot_reg (bool, default=True): - Whether to create a scatter plot of MDS results. rsid_or_chrompos (int, default=2): Format indicator for SNP IDs in the SNP data. Use 1 for `rsID` format or 2 for `chromosome_position`. """ @@ -96,7 +94,6 @@ def __init__( self.__masks_file = masks_file self.__distance_type = distance_type self.__n_components = n_components - self.__plot_reg = plot_reg self.__rsid_or_chrompos = rsid_or_chrompos self.__X_new_ = None # Store transformed SNP data @@ -126,11 +123,11 @@ def __setitem__(self, key, value): def copy(self) -> 'maasMDS': """ - Create and return a copy of the current `maasMDS` instance. + Create and return a copy of `self`. Returns: - maasMDS: - A new instance of the current object. + **maasMDS:** + A new instance of the current object. """ return copy.copy(self) @@ -140,7 +137,7 @@ def snpobj(self) -> Optional['SNPObject']: Retrieve `snpobj`. Returns: - SNPObject: A SNPObject object instance. + **SNPObject:** A SNPObject instance. """ return self.__snpobj @@ -157,7 +154,7 @@ def laiobj(self) -> Optional['LocalAncestryObject']: Retrieve `laiobj`. Returns: - LocalAncestryObject: A LAIObject instance. + **LocalAncestryObject:** A LAIObject instance. """ return self.__laiobj @@ -174,12 +171,8 @@ def labels_file(self) -> Optional[str]: Retrieve `labels_file`. Returns: - str: - Path to the labels file. It should be a `.tsv` file where the first column has header `indID` - and contains the individual identifiers, and the second column has header `label` and contains - the groups for all individuals. If `is_weighted=True`, a `weight` column with individual weights is required. - Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be combined - into groups, with respective weights. + **str:** + Path to the labels file in `.tsv` format. """ return self.__labels_file @@ -196,7 +189,7 @@ def ancestry(self) -> Optional[str]: Retrieve `ancestry`. Returns: - str: Target ancestry for analysis, such as 'AFR' or 'EUR'. + **str:** Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at `0`. """ return self.__ancestry @@ -213,7 +206,7 @@ def is_masked(self) -> bool: Retrieve `is_masked`. Returns: - bool: True if an ancestry file is passed for ancestry-specific masking, or False otherwise. + **bool:** True if an ancestry file is passed for ancestry-specific masking, or False otherwise. """ return self.__is_masked @@ -230,7 +223,7 @@ def prob_thresh(self) -> float: Retrieve `prob_thresh`. Returns: - float: Minimum probability threshold for a SNP to belong to an ancestry. + **float:** Minimum probability threshold for a SNP to belong to an ancestry. """ return self.__prob_thresh @@ -247,7 +240,7 @@ def average_strands(self) -> bool: Retrieve `average_strands`. Returns: - bool: True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. + **bool:** True if the haplotypes from the two parents are to be combined (averaged) for each individual, or False otherwise. """ return self.__average_strands @@ -264,7 +257,7 @@ def is_weighted(self) -> bool: Retrieve `is_weighted`. Returns: - bool: True if weights are provided in the labels file, or False otherwise. + **bool:** True if weights are provided in the labels file, or False otherwise. """ return self.__is_weighted @@ -276,20 +269,18 @@ def is_weighted(self, x: bool) -> None: self.__is_weighted = x @property - def groups_to_remove(self) -> dict: + def groups_to_remove(self) -> Dict[int, List[str]]: """ Retrieve `groups_to_remove`. Returns: - dict: - Dictionary specifying groups to exclude from analysis. Keys are array numbers, and values are - lists of groups to remove for each array. - Example: `{1: ['group1', 'group2'], 2: [], 3: ['group3']}`. + **dict of int to list of str:** Dictionary specifying groups to exclude from analysis. Keys are array numbers, and values are + lists of groups to remove for each array. Example: `{1: ['group1', 'group2'], 2: [], 3: ['group3']}`. """ return self.__groups_to_remove @groups_to_remove.setter - def groups_to_remove(self, x: dict) -> None: + def groups_to_remove(self, x: Dict[int, List[str]]) -> None: """ Update `groups_to_remove`. """ @@ -301,7 +292,7 @@ def min_percent_snps(self) -> float: Retrieve `min_percent_snps`. Returns: - float: + **float:** Minimum percentage of SNPs to be known in an individual for an individual to be included in the analysis. All individuals with fewer percent of unmasked SNPs than this threshold will be excluded. """ @@ -320,7 +311,7 @@ def save_masks(self) -> bool: Retrieve `save_masks`. Returns: - bool: True if the masked matrices are to be saved in a `.npz` file, or False otherwise. + **bool:** True if the masked matrices are to be saved in a `.npz` file, or False otherwise. """ return self.__save_masks @@ -337,7 +328,9 @@ def load_masks(self) -> bool: Retrieve `load_masks`. Returns: - bool: True if the masked matrices are to be loaded from pre-existing `.npz` file (`masks_file`), or False otherwise. + **bool:** + True if the masked matrices are to be loaded from a pre-existing `.npz` file specified + by `masks_file`, or False otherwise. """ return self.__load_masks @@ -349,17 +342,17 @@ def load_masks(self, x: bool) -> None: self.__load_masks = x @property - def masks_file(self) -> str: + def masks_file(self) -> Union[str, pathlib.Path]: """ Retrieve `masks_file`. Returns: - str: Path to the `.npz` file used for saving/loading masked matrices. + **str or pathlib.Path:** Path to the `.npz` file used for saving/loading masked matrices. """ return self.__masks_file @masks_file.setter - def masks_file(self, x: str) -> None: + def masks_file(self, x: Union[str, pathlib.Path]) -> None: """ Update `masks_file`. """ @@ -371,7 +364,7 @@ def distance_type(self) -> str: Retrieve `distance_type`. Returns: - str: + **str:** Distance metric to use. Options to choose from are: 'Manhattan', 'RMS' (Root Mean Square), 'AP' (Average Pairwise). If `average_strands=True`, use 'distance_type=AP'. """ @@ -390,7 +383,7 @@ def n_components(self) -> int: Retrieve `n_components`. Returns: - int: The number of principal components. + **int:** The number of principal components. """ return self.__n_components @@ -401,30 +394,13 @@ def n_components(self, x: int) -> None: """ self.__n_components = x - @property - def plot_reg(self) -> bool: - """ - Retrieve `plot_reg`. - - Returns: - bool: Whether to create a scatter plot of MDS results. - """ - return self.__plot_reg - - @plot_reg.setter - def plot_reg(self, x: bool) -> None: - """ - Update `plot_reg`. - """ - self.__plot_reg = x - @property def rsid_or_chrompos(self) -> int: """ Retrieve `rsid_or_chrompos`. Returns: - int: Format indicator for SNP IDs in the SNP data. Use 1 for `rsID` format or 2 for `chromosome_position`. + **int:** Format indicator for SNP IDs in the SNP data. Use 1 for `rsID` format or 2 for `chromosome_position`. """ return self.__rsid_or_chrompos @@ -435,170 +411,14 @@ def rsid_or_chrompos(self, x: int) -> None: """ self.__rsid_or_chrompos = x - @property - def snpobj(self): - """Retrieve `snpobj`.""" - return self.__snpobj - - @snpobj.setter - def snpobj(self, value): - """Update `snpobj`.""" - self.__snpobj = x - - @property - def laiobj(self): - """Retrieve `laiobj`.""" - return self.__laiobj - - @laiobj.setter - def laiobj(self, value): - """Update `laiobj`.""" - self.__laiobj = x - - @property - def labels_file(self) -> str: - """Retrieve `labels_file`: Path to the labels file.""" - return self.__labels_file - - @labels_file.setter - def labels_file(self, x: str) -> None: - """Update `labels_file`.""" - self.__labels_file = x - - @property - def ancestry(self) -> str: - """Retrieve `ancestry`: Target ancestry for analysis.""" - return self.__ancestry - - @ancestry.setter - def ancestry(self, x: str) -> None: - """Update `ancestry`.""" - self.__ancestry = x - - @property - def is_masked(self) -> bool: - """Indicates if ancestry masking is applied.""" - return self.__is_masked - - @is_masked.setter - def is_masked(self, x: bool) -> None: - self.__is_masked = x - - @property - def prob_thresh(self) -> float: - """Probability threshold for masking ancestry-specific SNPs.""" - return self.__prob_thresh - - @prob_thresh.setter - def prob_thresh(self, x: float) -> None: - self.__prob_thresh = x - - @property - def average_strands(self) -> bool: - """If True, averages SNP values from both parents.""" - return self.__average_strands - - @average_strands.setter - def average_strands(self, x: bool) -> None: - self.__average_strands = x - - @property - def groups_to_remove(self) -> dict: - """Groups to exclude from analysis.""" - return self.__groups_to_remove - - @groups_to_remove.setter - def groups_to_remove(self, x: dict) -> None: - self.__groups_to_remove = x - - @property - def min_percent_snps(self) -> float: - """Minimum percentage of SNPs for inclusion.""" - return self.__min_percent_snps - - @min_percent_snps.setter - def min_percent_snps(self, x: float) -> None: - self.__min_percent_snps = x - - @property - def is_weighted(self) -> bool: - """If weights are provided in the labels file.""" - return self.__is_weighted - - @is_weighted.setter - def is_weighted(self, x: bool) -> None: - self.__is_weighted = x - - @property - def save_masks(self) -> bool: - """If True, saves the generated masked matrices.""" - return self.__save_masks - - @save_masks.setter - def save_masks(self, x: bool) -> None: - self.__save_masks = x - - @property - def load_masks(self) -> bool: - """If True, loads pre-existing masked matrices.""" - return self.__load_masks - - @load_masks.setter - def load_masks(self, x: bool) -> None: - self.__load_masks = x - - @property - def masks_file(self) -> str: - """Path to the `.npz` file for masks.""" - return self.__masks_file - - @masks_file.setter - def masks_file(self, x: str) -> None: - self.__masks_file = x - - @property - def distance_type(self) -> str: - """Type of distance metric to use in MDS.""" - return self.__distance_type - - @distance_type.setter - def distance_type(self, x: str) -> None: - self.__distance_type = x - - @property - def n_components(self) -> int: - """Number of dimensions for MDS.""" - return self.__n_components - - @n_components.setter - def n_components(self, x: int) -> None: - self.__n_components = x - - @property - def plot_reg(self) -> bool: - """If True, create a scatter plot of MDS results.""" - return self.__plot_reg - - @plot_reg.setter - def plot_reg(self, x: bool) -> None: - self.__plot_reg = x - - @property - def rsid_or_chrompos(self) -> int: - """Format indicator for SNP IDs.""" - return self.__rsid_or_chrompos - - @rsid_or_chrompos.setter - def rsid_or_chrompos(self, x: int) -> None: - self.__rsid_or_chrompos = x - @property def X_new_(self) -> Optional[np.ndarray]: """ Retrieve `X_new_`. Returns: - numpy.ndarray: The transformed SNP data onto the `n_components` principal components. + **array of shape (n_samples, n_components):** + The transformed SNP data projected onto the `n_components` principal components. """ return self.__X_new_ @@ -628,19 +448,18 @@ def fit_transform( average_strands: Optional[bool] = None ) -> np.ndarray: """ - Fit and transform SNP data in `snpobj` into a lower-dimensional space. + Fit the model to the SNP data stored in the provided `snpobj` and apply the dimensionality reduction on the same SNP data. Args: snpobj (SNPObject, optional): - A SNPObject object instance. + A SNPObject instance. laiobj (LAIObject, optional): A LAIObject instance. labels_file (str, optional): - Path to the labels file. It should be a `.tsv` file where the first column has header `indID` - and contains the individual identifiers, and the second column has header `label` and contains - the groups for all individuals. If `is_weighted=True`, a `weight` column with individual weights is required. - Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be combined - into groups, with respective weights. + Path to the labels file in .tsv format. The first column, `indID`, contains the individual identifiers, and the second + column, `label`, specifies the groups for all individuals. If `is_weighted=True`, a `weight` column with individual + weights is required. Optionally, `combination` and `combination_weight` columns can specify sets of individuals to be + combined into groups, with respective weights. ancestry (str, optional): Ancestry for which dimensionality reduction is to be performed. Ancestry counter starts at 0. average_strands (bool, optional): @@ -648,7 +467,8 @@ def fit_transform( If None, defaults to `self.average_strands`. Returns: - numpy.ndarray: The transformed SNP data onto the `n_components` principal components, stored in `self.X_new_`. + **array of shape (n_samples, n_components):** + The transformed SNP data projected onto the `n_components` principal components, stored in `self.X_new_`. """ if snpobj is None: snpobj = self.snpobj From f145ea7a18bba65ddf29cf1c9841b1c9d83f9173 Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 17:23:32 -0500 Subject: [PATCH 10/11] Fix bugs in bed.py: pathlib handling and snpobj modification - Fixed issue with when handling filenames not ending in . - Resolved unintended modification of within the BEDWriter class by using a deep copy. --- demos/SNPObj.ipynb | 134 ++++++++++++++++++++++++----------- snputils/snp/io/write/bed.py | 4 +- 2 files changed, 93 insertions(+), 45 deletions(-) diff --git a/demos/SNPObj.ipynb b/demos/SNPObj.ipynb index 210995b..8ae372b 100644 --- a/demos/SNPObj.ipynb +++ b/demos/SNPObj.ipynb @@ -248,7 +248,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "dcf2c069", "metadata": {}, "outputs": [ @@ -281,7 +281,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "7c64f7ca", "metadata": {}, "outputs": [ @@ -311,7 +311,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "30807670", "metadata": {}, "outputs": [ @@ -319,13 +319,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Unique genotype values before renaming missings: [0 1]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "Unique genotype values before renaming missings: [0 1]\n", "Unique genotype values after renaming missings: [0 1]\n" ] } @@ -352,7 +346,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "9beadb42", "metadata": {}, "outputs": [ @@ -390,7 +384,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "4cf4d34e", "metadata": {}, "outputs": [ @@ -422,7 +416,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "89ae6d84", "metadata": {}, "outputs": [ @@ -458,7 +452,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "4fa41771", "metadata": {}, "outputs": [ @@ -496,7 +490,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "ba4d2066", "metadata": {}, "outputs": [ @@ -504,13 +498,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Samples before filtering: ['sample_A', 'sample_B', 'sample_C', 'sample_D']\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "Samples before filtering: ['sample_A', 'sample_B', 'sample_C', 'sample_D']\n", "Samples after filtering: ['sample_B', 'sample_C']\n" ] } @@ -734,7 +722,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "First 5 variant positions after shuffling: [45903479 41864913 32108927 26948879 19065364]\n" + "First 5 variant positions after shuffling: [23614817 40647956 44287560 41652214 42829912]\n" ] } ], @@ -766,13 +754,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Variants_ref before handling empty entries: ['' 'G' 'G' 'C' 'A']\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "Variants_ref before handling empty entries: ['' 'G' 'G' 'C' 'A']\n", "Variants_ref after handling empty entries: ['.' 'G' 'G' 'C' 'A']\n" ] } @@ -808,7 +790,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 28, "id": "544b2955", "metadata": {}, "outputs": [ @@ -816,18 +798,72 @@ "name": "stdout", "output_type": "stream", "text": [ - "SNPObject saved to ../data/output.vcf\n" + "SNPObject saved to ../data/output.vcf\n", + "SNPObject saved to ../data/output.phased\n" ] } ], "source": [ "# Define the path to save the VCF file\n", - "output_vcf_path = '../data/output.vcf'\n", + "output_vcf_path1 = '../data/output.vcf'\n", + "output_vcf_path2 = '../data/output.phased'\n", "\n", - "# Save the SNPObject as a VCF file\n", - "snpobj.save(output_vcf_path)\n", + "# Save the SNPObject as a VCF file (Option 1)\n", + "snpobj.save(output_vcf_path1)\n", + "print(f\"SNPObject saved to {output_vcf_path1}\")\n", "\n", - "print(f\"SNPObject saved to {output_vcf_path}\")" + "# Save the SNPObject as a VCF file (Option 2)\n", + "snpobj.save_vcf(output_vcf_path2)\n", + "print(f\"SNPObject saved to {output_vcf_path2}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a85bcfcf", + "metadata": {}, + "source": [ + "**Saving as PGEN**" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "08eff0b2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.pvar\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO:snputils.snp.io.write.pgen:Writing ../data/output.psam\n", + "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.pgen\n", + "SNPObject saved to ../data/output.pgen\n", + "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.phased.pvar\n", + "INFO:snputils.snp.io.write.pgen:Writing ../data/output.phased.psam\n", + "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.phased.pgen\n", + "SNPObject saved to ../data/output.phased\n" + ] + } + ], + "source": [ + "# Define the path to save the BED file\n", + "output_pgen_path1 = '../data/output.pgen'\n", + "output_pgen_path2 = '../data/output.phased'\n", + "\n", + "# Save the SNPObject as a PGEN file (option 1)\n", + "snpobj.save(output_pgen_path1)\n", + "print(f\"SNPObject saved to {output_pgen_path1}\")\n", + "\n", + "# Save the SNPObject as a PGEN file (option 2)\n", + "snpobj.save_pgen(output_pgen_path2)\n", + "print(f\"SNPObject saved to {output_pgen_path2}\")" ] }, { @@ -840,7 +876,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 25, "id": "fbca1fd8", "metadata": {}, "outputs": [ @@ -855,18 +891,30 @@ "INFO:snputils.snp.io.write.bed:Writing .bim file: ../data/output\n", "WARNING:snputils.snp.io.write.bed:The .bim file is being saved with 0 cM values.\n", "INFO:snputils.snp.io.write.bed:Finished writing .bim file: ../data/output\n", - "SNPObject saved to ../data/output.bed\n" + "SNPObject saved to ../data/output.bed\n", + "INFO:snputils.snp.io.write.bed:Writing .bed file: ../data/output.bed\n", + "INFO:snputils.snp.io.write.bed:Finished writing .bed file: ../data/output.bed\n", + "INFO:snputils.snp.io.write.bed:Writing .fam file: ../data/output\n", + "INFO:snputils.snp.io.write.bed:Finished writing .fam file: ../data/output\n", + "INFO:snputils.snp.io.write.bed:Writing .bim file: ../data/output\n", + "WARNING:snputils.snp.io.write.bed:The .bim file is being saved with 0 cM values.\n", + "INFO:snputils.snp.io.write.bed:Finished writing .bim file: ../data/output\n", + "SNPObject saved to ../data/output.phased\n" ] } ], "source": [ "# Define the path to save the BED file\n", - "output_bed_path = '../data/output.bed'\n", + "output_bed_path1 = '../data/output.bed'\n", + "output_bed_path2 = '../data/output.phased'\n", "\n", - "# Save the SNPObject as a BED file\n", - "snpobj.save(output_bed_path)\n", + "# Save the SNPObject as a BED file (option 1)\n", + "snpobj.save(output_bed_path1)\n", + "print(f\"SNPObject saved to {output_bed_path1}\")\n", "\n", - "print(f\"SNPObject saved to {output_bed_path}\")" + "# Save the SNPObject as a BED file (option 2)\n", + "snpobj.save_bed(output_bed_path2)\n", + "print(f\"SNPObject saved to {output_bed_path2}\")" ] }, { @@ -879,7 +927,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 26, "id": "cea856ad", "metadata": {}, "outputs": [ diff --git a/snputils/snp/io/write/bed.py b/snputils/snp/io/write/bed.py index 97cf854..2c19b40 100644 --- a/snputils/snp/io/write/bed.py +++ b/snputils/snp/io/write/bed.py @@ -19,7 +19,7 @@ class BEDWriter: """ def __init__(self, snpobj: SNPObject, filename: str): - self.__snpobj = snpobj + self.__snpobj = snpobj.copy() self.__filename = Path(filename) def write(self): @@ -27,7 +27,7 @@ def write(self): # Save .bed file if self.__filename.suffix != '.bed': - self.__filename = self.__filename.with_name(self.__filename.with_suffix('.bed')) + self.__filename = self.__filename.with_suffix('.bed') log.info(f"Writing .bed file: {self.__filename}") From c69d673b2478e9302976556d2a8e7a5c3c28ca45 Mon Sep 17 00:00:00 2001 From: miriambt Date: Fri, 15 Nov 2024 17:26:39 -0500 Subject: [PATCH 11/11] Change output path names to unphased suffix in SNPObj.ipynb --- demos/SNPObj.ipynb | 49 +++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/demos/SNPObj.ipynb b/demos/SNPObj.ipynb index 8ae372b..4559d4c 100644 --- a/demos/SNPObj.ipynb +++ b/demos/SNPObj.ipynb @@ -790,7 +790,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 29, "id": "544b2955", "metadata": {}, "outputs": [ @@ -799,14 +799,14 @@ "output_type": "stream", "text": [ "SNPObject saved to ../data/output.vcf\n", - "SNPObject saved to ../data/output.phased\n" + "SNPObject saved to ../data/output.unphased\n" ] } ], "source": [ "# Define the path to save the VCF file\n", "output_vcf_path1 = '../data/output.vcf'\n", - "output_vcf_path2 = '../data/output.phased'\n", + "output_vcf_path2 = '../data/output.unphased'\n", "\n", "# Save the SNPObject as a VCF file (Option 1)\n", "snpobj.save(output_vcf_path1)\n", @@ -827,7 +827,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 30, "id": "08eff0b2", "metadata": {}, "outputs": [ @@ -835,27 +835,21 @@ "name": "stdout", "output_type": "stream", "text": [ - "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.pvar\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.pvar\n", "INFO:snputils.snp.io.write.pgen:Writing ../data/output.psam\n", "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.pgen\n", "SNPObject saved to ../data/output.pgen\n", - "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.phased.pvar\n", - "INFO:snputils.snp.io.write.pgen:Writing ../data/output.phased.psam\n", - "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.phased.pgen\n", - "SNPObject saved to ../data/output.phased\n" + "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.unphased.pvar\n", + "INFO:snputils.snp.io.write.pgen:Writing ../data/output.unphased.psam\n", + "INFO:snputils.snp.io.write.pgen:Writing to ../data/output.unphased.pgen\n", + "SNPObject saved to ../data/output.unphased\n" ] } ], "source": [ "# Define the path to save the BED file\n", "output_pgen_path1 = '../data/output.pgen'\n", - "output_pgen_path2 = '../data/output.phased'\n", + "output_pgen_path2 = '../data/output.unphased'\n", "\n", "# Save the SNPObject as a PGEN file (option 1)\n", "snpobj.save(output_pgen_path1)\n", @@ -876,7 +870,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 31, "id": "fbca1fd8", "metadata": {}, "outputs": [ @@ -899,14 +893,14 @@ "INFO:snputils.snp.io.write.bed:Writing .bim file: ../data/output\n", "WARNING:snputils.snp.io.write.bed:The .bim file is being saved with 0 cM values.\n", "INFO:snputils.snp.io.write.bed:Finished writing .bim file: ../data/output\n", - "SNPObject saved to ../data/output.phased\n" + "SNPObject saved to ../data/output.unphased\n" ] } ], "source": [ "# Define the path to save the BED file\n", "output_bed_path1 = '../data/output.bed'\n", - "output_bed_path2 = '../data/output.phased'\n", + "output_bed_path2 = '../data/output.unphased'\n", "\n", "# Save the SNPObject as a BED file (option 1)\n", "snpobj.save(output_bed_path1)\n", @@ -927,7 +921,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 33, "id": "cea856ad", "metadata": {}, "outputs": [ @@ -935,18 +929,23 @@ "name": "stdout", "output_type": "stream", "text": [ - "SNPObject saved to ../data/output.pkl\n" + "SNPObject saved to ../data/output.pkl\n", + "SNPObject saved to ../data/output.unphased\n" ] } ], "source": [ "# Define the path to save the pickle file\n", - "output_pkl_path = '../data/output.pkl'\n", + "output_pkl_path1 = '../data/output.pkl'\n", + "output_pkl_path2 = '../data/output.unphased'\n", "\n", - "# Save the SNPObject as a pickle file\n", - "snpobj.save(output_pkl_path)\n", + "# Save the SNPObject as a pickle file (option 1)\n", + "snpobj.save(output_pkl_path1)\n", + "print(f\"SNPObject saved to {output_pkl_path1}\")\n", "\n", - "print(f\"SNPObject saved to {output_pkl_path}\")" + "# Save the SNPObject as a pickle file (option 2)\n", + "snpobj.save_pickle(output_pkl_path2)\n", + "print(f\"SNPObject saved to {output_pkl_path2}\")" ] }, {