From c4edff8b0f113e27fee878446eccbead94a59f77 Mon Sep 17 00:00:00 2001 From: Jeff Newman Date: Tue, 1 Mar 2022 09:33:38 -0600 Subject: [PATCH 001/377] sharrow changes initial dump --- .gitignore | 1 + activitysim/abm/models/accessibility.py | 3 + activitysim/abm/models/initialize.py | 12 +- activitysim/abm/models/location_choice.py | 18 +- activitysim/abm/models/trip_destination.py | 25 +- activitysim/abm/models/trip_matrices.py | 17 +- activitysim/abm/models/trip_mode_choice.py | 6 + .../abm/models/util/tour_destination.py | 8 +- .../models/util/vectorize_tour_scheduling.py | 26 +- activitysim/abm/tables/accessibility.py | 15 +- activitysim/abm/tables/households.py | 4 + activitysim/abm/tables/landuse.py | 4 + activitysim/abm/tables/persons.py | 4 + .../test_load_cached_accessibility.py | 14 +- activitysim/benchmarking/__init__.py | 1 + activitysim/benchmarking/asv.conf.json | 174 +++++ .../benchmarking/benchmarks/__init__.py | 0 .../benchmarking/benchmarks/mtc1full.py | 91 +++ .../benchmarking/benchmarks/mtc1mp4.py | 81 ++ .../benchmarking/benchmarks/sandag1example.py | 43 ++ .../benchmarking/benchmarks/sandag1full.py | 43 ++ .../benchmarking/benchmarks/sandag2example.py | 43 ++ .../benchmarking/benchmarks/sandag2full.py | 43 ++ .../benchmarking/benchmarks/sandag3example.py | 43 ++ .../benchmarking/benchmarks/sandag3full.py | 43 ++ .../benchmarking/benchmarks/sandag_example.py | 42 ++ .../benchmarking/benchmarks/sandag_full.py | 41 ++ activitysim/benchmarking/componentwise.py | 691 ++++++++++++++++++ activitysim/benchmarking/instrument.py | 27 + activitysim/benchmarking/latest.py | 181 +++++ activitysim/benchmarking/profile_inspector.py | 106 +++ activitysim/benchmarking/reader.py | 28 + activitysim/benchmarking/workspace.py | 20 + activitysim/cli/benchmark.py | 297 ++++++++ activitysim/cli/cli.py | 6 +- activitysim/cli/create.py | 36 +- activitysim/cli/main.py | 26 +- activitysim/cli/run.py | 19 +- activitysim/core/assign.py | 57 +- activitysim/core/choosing.py | 89 +++ activitysim/core/chunk.py | 9 +- activitysim/core/cleaning.py | 0 activitysim/core/config.py | 75 +- activitysim/core/configuration.py | 263 +++++++ activitysim/core/expressions.py | 8 +- activitysim/core/fast_mapping.py | 56 ++ activitysim/core/flow.py | 636 ++++++++++++++++ activitysim/core/input.py | 23 +- activitysim/core/interaction_sample.py | 236 +++--- activitysim/core/interaction_simulate.py | 349 ++++++--- activitysim/core/logit.py | 37 +- activitysim/core/los.py | 89 ++- activitysim/core/mem.py | 4 + activitysim/core/mp_tasks.py | 39 +- activitysim/core/pipeline.py | 55 +- activitysim/core/random.py | 17 +- activitysim/core/simulate.py | 220 ++++-- activitysim/core/simulate_consts.py | 5 + activitysim/core/skim_dataset.py | 326 +++++++++ activitysim/core/skim_dict_factory.py | 3 +- activitysim/core/skim_dictionary.py | 12 +- activitysim/core/steps/output.py | 7 +- activitysim/core/test/test_logit.py | 7 +- activitysim/core/test/test_simulate.py | 4 +- activitysim/core/timetable.py | 192 +++-- activitysim/core/tracing.py | 24 +- activitysim/core/util.py | 23 +- .../estimation/larch/location_choice.py | 33 +- activitysim/examples/__init__.py | 0 activitysim/examples/example_manifest.yaml | 69 +- .../examples/example_mtc/configs/logging.yaml | 12 +- .../example_mtc/configs/settings.yaml | 11 +- .../example_mtc/configs/trip_destination.csv | 4 +- ...estination_annotate_trips_preprocessor.csv | 5 +- .../configs/trip_destination_sample.csv | 12 +- .../example_mtc/configs/trip_mode_choice.csv | 46 +- ...ode_choice_annotate_trips_preprocessor.csv | 2 + .../configs_chunktrain/logging.yaml | 70 ++ .../configs_chunktrain/settings.yaml | 85 +++ .../configs_production/logging.yaml | 71 ++ .../configs_production/settings.yaml | 85 +++ .../example_mtc/configs_sh/settings.yaml | 3 + .../configs_sh_compile/settings.yaml | 5 + .../examples/example_mtc/sh-exercise.sh | 8 + .../configs_3_zone/logging.yaml | 6 +- .../configs_benchmarking/settings.yaml | 146 ++++ .../data_3/cached_accessibility.csv.gz | Bin 0 -> 1663439 bytes activitysim/examples/optimize_example_data.py | 39 + activitysim/preconfigure.py | 12 + conda-environments/activitysim-dev.yml | 9 +- 90 files changed, 5317 insertions(+), 563 deletions(-) create mode 100644 activitysim/benchmarking/__init__.py create mode 100644 activitysim/benchmarking/asv.conf.json create mode 100644 activitysim/benchmarking/benchmarks/__init__.py create mode 100644 activitysim/benchmarking/benchmarks/mtc1full.py create mode 100644 activitysim/benchmarking/benchmarks/mtc1mp4.py create mode 100644 activitysim/benchmarking/benchmarks/sandag1example.py create mode 100644 activitysim/benchmarking/benchmarks/sandag1full.py create mode 100644 activitysim/benchmarking/benchmarks/sandag2example.py create mode 100644 activitysim/benchmarking/benchmarks/sandag2full.py create mode 100644 activitysim/benchmarking/benchmarks/sandag3example.py create mode 100644 activitysim/benchmarking/benchmarks/sandag3full.py create mode 100644 activitysim/benchmarking/benchmarks/sandag_example.py create mode 100644 activitysim/benchmarking/benchmarks/sandag_full.py create mode 100644 activitysim/benchmarking/componentwise.py create mode 100644 activitysim/benchmarking/instrument.py create mode 100644 activitysim/benchmarking/latest.py create mode 100644 activitysim/benchmarking/profile_inspector.py create mode 100644 activitysim/benchmarking/reader.py create mode 100644 activitysim/benchmarking/workspace.py create mode 100644 activitysim/cli/benchmark.py create mode 100644 activitysim/core/choosing.py create mode 100644 activitysim/core/cleaning.py create mode 100644 activitysim/core/configuration.py create mode 100644 activitysim/core/fast_mapping.py create mode 100644 activitysim/core/flow.py create mode 100644 activitysim/core/simulate_consts.py create mode 100644 activitysim/core/skim_dataset.py create mode 100644 activitysim/examples/__init__.py create mode 100644 activitysim/examples/example_mtc/configs_chunktrain/logging.yaml create mode 100644 activitysim/examples/example_mtc/configs_chunktrain/settings.yaml create mode 100644 activitysim/examples/example_mtc/configs_production/logging.yaml create mode 100644 activitysim/examples/example_mtc/configs_production/settings.yaml create mode 100644 activitysim/examples/example_mtc/configs_sh/settings.yaml create mode 100644 activitysim/examples/example_mtc/configs_sh_compile/settings.yaml create mode 100644 activitysim/examples/example_mtc/sh-exercise.sh create mode 100644 activitysim/examples/example_sandag/configs_benchmarking/settings.yaml create mode 100644 activitysim/examples/example_sandag/data_3/cached_accessibility.csv.gz create mode 100644 activitysim/examples/optimize_example_data.py create mode 100644 activitysim/preconfigure.py diff --git a/.gitignore b/.gitignore index 41dc6bbd2..2774fabfc 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ _test_est *_local/ *_local.* +**/__sharrowcache__ diff --git a/activitysim/abm/models/accessibility.py b/activitysim/abm/models/accessibility.py index 16d0e8559..328744374 100644 --- a/activitysim/abm/models/accessibility.py +++ b/activitysim/abm/models/accessibility.py @@ -52,6 +52,7 @@ def compute_accessibilities_for_zones( trace_od_rows = None # merge land_use_columns into od_df + logger.info(f"{trace_label}: merge land_use_columns into od_df") od_df = pd.merge(od_df, land_use_df, left_on='dest', right_index=True).sort_index() chunk.log_df(trace_label, "od_df", od_df) @@ -69,11 +70,13 @@ def compute_accessibilities_for_zones( if network_los.zone_system == los.THREE_ZONE: locals_d['tvpb'] = network_los.tvpb + logger.info(f"{trace_label}: assign.assign_variables") results, trace_results, trace_assigned_locals \ = assign.assign_variables(assignment_spec, od_df, locals_d, trace_rows=trace_od_rows, trace_label=trace_label, chunk_log=True) chunk.log_df(trace_label, "results", results) + logger.info(f"{trace_label}: have results") # accessibility_df = accessibility_df.copy() for column in results.columns: diff --git a/activitysim/abm/models/initialize.py b/activitysim/abm/models/initialize.py index d72a88f9d..b0f05ab42 100644 --- a/activitysim/abm/models/initialize.py +++ b/activitysim/abm/models/initialize.py @@ -184,11 +184,11 @@ def preload_injectables(): t0 = tracing.print_elapsed_time() - # FIXME - still want to do this? - # if inject.get_injectable('skim_dict', None) is not None: - # t0 = tracing.print_elapsed_time("preload skim_dict", t0, debug=True) - # - # if inject.get_injectable('skim_stack', None) is not None: - # t0 = tracing.print_elapsed_time("preload skim_stack", t0, debug=True) + if config.setting('benchmarking', False): + # we don't want to pay for skim_dict inside any model component during + # benchmarking, so we'll preload skim_dict here. Preloading is not needed + # for regular operation, as activitysim components can load-on-demand. + if inject.get_injectable('skim_dict', None) is not None: + t0 = tracing.print_elapsed_time("preload skim_dict", t0, debug=True) return True diff --git a/activitysim/abm/models/location_choice.py b/activitysim/abm/models/location_choice.py index 5b3b7d1e4..fb5de3a2b 100644 --- a/activitysim/abm/models/location_choice.py +++ b/activitysim/abm/models/location_choice.py @@ -139,7 +139,10 @@ def _location_sample( locals_d = { 'skims': skims, - 'segment_size': segment_name + 'segment_size': segment_name, + 'orig_col_name': skims.orig_key, # added for sharrow flows + 'dest_col_name': skims.dest_key, # added for sharrow flows + 'timeframe': 'timeless', } constants = config.get_model_constants(model_settings) locals_d.update(constants) @@ -500,7 +503,10 @@ def run_location_simulate( locals_d = { 'skims': skims, - 'segment_size': segment_name + 'segment_size': segment_name, + 'orig_col_name': skims.orig_key, # added for sharrow flows + 'dest_col_name': skims.dest_key, # added for sharrow flows + 'timeframe': 'timeless', } constants = config.get_model_constants(model_settings) if constants is not None: @@ -886,6 +892,10 @@ def workplace_location( # if multiprocessing.current_process().name =='mp_households_0': # raise RuntimeError(f"fake fail {process_name}") + # disable locutor for benchmarking + if config.setting('benchmarking', False): + locutor = False + iterate_location_choice( model_settings, persons_merged, persons, households, @@ -917,6 +927,10 @@ def school_location( if estimator: write_estimation_specs(estimator, model_settings, 'school_location.yaml') + # disable locutor for benchmarking + if config.setting('benchmarking', False): + locutor = False + iterate_location_choice( model_settings, persons_merged, persons, households, diff --git a/activitysim/abm/models/trip_destination.py b/activitysim/abm/models/trip_destination.py index e4dbed444..247a51c7d 100644 --- a/activitysim/abm/models/trip_destination.py +++ b/activitysim/abm/models/trip_destination.py @@ -92,7 +92,9 @@ def _destination_sample( # (unless we iterate over trip.purpose - which we could, though we are already iterating over trip_num) # so, instead, expressions determine row-specific size_term by a call to: size_terms.get(df.alt_dest, df.purpose) locals_dict.update({ - 'size_terms': size_term_matrix + 'size_terms': size_term_matrix, + 'size_terms_array': size_term_matrix.df.to_numpy(), + 'timeframe': 'trip', }) locals_dict.update(skims) @@ -601,6 +603,7 @@ def compute_logsums( "odt_skims": skims['odt_skims'], "dot_skims": skims['dot_skims'], "od_skims": skims['od_skims'], + "timeframe": "trip", } if network_los.zone_system == los.THREE_ZONE: od_skims.update({ @@ -679,9 +682,17 @@ def trip_destination_simulate( skims = skim_hotel.sample_skims(presample=False) + if not np.issubdtype(trips['trip_period'].dtype, np.integer): + if hasattr(skims['odt_skims'], 'map_time_periods'): + trip_period_idx = skims['odt_skims'].map_time_periods(trips) + if trip_period_idx is not None: + trips['trip_period'] = trip_period_idx + locals_dict = config.get_model_constants(model_settings).copy() locals_dict.update({ - 'size_terms': size_term_matrix + 'size_terms': size_term_matrix, + 'size_terms_array': size_term_matrix.df.to_numpy(), + 'timeframe': 'trip', }) locals_dict.update(skims) @@ -1011,7 +1022,8 @@ def run_trip_destination( nth_trace_label = tracing.extend_trace_label(trace_label, 'trip_num_%s' % trip_num) locals_dict = { - 'network_los': network_los + 'network_los': network_los, + 'size_terms': size_term_matrix, } locals_dict.update(config.get_model_constants(model_settings)) @@ -1023,6 +1035,13 @@ def run_trip_destination( locals_dict=locals_dict, trace_label=nth_trace_label) + if not np.issubdtype(nth_trips['trip_period'].dtype, np.integer): + skims = network_los.get_default_skim_dict() + if hasattr(skims, 'map_time_periods_from_series'): + trip_period_idx = skims.map_time_periods_from_series(nth_trips['trip_period']) + if trip_period_idx is not None: + nth_trips['trip_period'] = trip_period_idx + logger.info("Running %s with %d trips", nth_trace_label, nth_trips.shape[0]) # - choose destination for nth_trips, segmented by primary_purpose diff --git a/activitysim/abm/models/trip_matrices.py b/activitysim/abm/models/trip_matrices.py index 2df091c01..8f69ef03c 100644 --- a/activitysim/abm/models/trip_matrices.py +++ b/activitysim/abm/models/trip_matrices.py @@ -54,6 +54,7 @@ def write_trip_matrices(network_los): parking_settings = config.read_model_settings('parking_location_choice.yaml') parking_taz_col_name = parking_settings['ALT_DEST_COL_NAME'] if parking_taz_col_name in trips_df: + # TODO make parking zone negative, not zero, if not used trips_df.loc[trips_df[parking_taz_col_name] > 0, 'destination'] = trips_df[parking_taz_col_name] # Also need address the return trip @@ -72,14 +73,20 @@ def write_trip_matrices(network_los): dest_vals = aggregate_trips.index.get_level_values('destination') # use the land use table for the set of possible tazs - zone_index = pipeline.get_table('land_use').index + land_use = pipeline.get_table('land_use') + zone_index = land_use.index assert all(zone in zone_index for zone in orig_vals) assert all(zone in zone_index for zone in dest_vals) _, orig_index = zone_index.reindex(orig_vals) _, dest_index = zone_index.reindex(dest_vals) - write_matrices(aggregate_trips, zone_index, orig_index, dest_index, model_settings) + try: + zone_labels = land_use[f'_original_{land_use.index.name}'] + except KeyError: + zone_labels = land_use.index + + write_matrices(aggregate_trips, zone_labels, orig_index, dest_index, model_settings) elif network_los.zone_system == los.TWO_ZONE: # maz trips written to taz matrices logger.info('aggregating trips two zone...') @@ -184,6 +191,12 @@ def annotate_trips(trips, network_los, model_settings): trips_df, locals_dict, skims, model_settings, trace_label) + if not np.issubdtype(trips_df['trip_period'].dtype, np.integer): + if hasattr(skim_dict, 'map_time_periods_from_series'): + trip_period_idx = skim_dict.map_time_periods_from_series(trips_df['trip_period']) + if trip_period_idx is not None: + trips_df['trip_period'] = trip_period_idx + # Data will be expanded by an expansion weight column from # the households pipeline table, if specified in the model settings. hh_weight_col = model_settings.get('HH_EXPANSION_WEIGHT_COL') diff --git a/activitysim/abm/models/trip_mode_choice.py b/activitysim/abm/models/trip_mode_choice.py index 59e9fc5e5..532ca776c 100644 --- a/activitysim/abm/models/trip_mode_choice.py +++ b/activitysim/abm/models/trip_mode_choice.py @@ -97,6 +97,11 @@ def trip_mode_choice( dim3_key='trip_period') od_skim_wrapper = skim_dict.wrap('origin', 'destination') + if hasattr(skim_dict, 'map_time_periods_from_series'): + trip_period_idx = skim_dict.map_time_periods_from_series(trips_merged['trip_period']) + if trip_period_idx is not None: + trips_merged['trip_period'] = trip_period_idx + skims = { "odt_skims": odt_skim_stack_wrapper, "dot_skims": dot_skim_stack_wrapper, @@ -182,6 +187,7 @@ def trip_mode_choice( estimator.write_choosers(trips_segment) locals_dict.update(skims) + locals_dict['timeframe'] = 'trip' choices = mode_choice_simulate( choosers=trips_segment, diff --git a/activitysim/abm/models/util/tour_destination.py b/activitysim/abm/models/util/tour_destination.py index 7482aeb6d..c18bacbde 100644 --- a/activitysim/abm/models/util/tour_destination.py +++ b/activitysim/abm/models/util/tour_destination.py @@ -91,7 +91,10 @@ def _destination_sample( sample_size = 0 locals_d = { - 'skims': skims + 'skims': skims, + 'orig_col_name': skims.orig_key, # added for sharrow flows + 'dest_col_name': skims.dest_key, # added for sharrow flows + 'timeframe': 'timeless', } constants = config.get_model_constants(model_settings) if constants is not None: @@ -612,6 +615,9 @@ def run_destination_simulate( locals_d = { 'skims': skims, + 'orig_col_name': skims.orig_key, # added for sharrow flows + 'dest_col_name': skims.dest_key, # added for sharrow flows + 'timeframe': 'timeless', } if constants is not None: locals_d.update(constants) diff --git a/activitysim/abm/models/util/vectorize_tour_scheduling.py b/activitysim/abm/models/util/vectorize_tour_scheduling.py index 199059e2a..da437938c 100644 --- a/activitysim/abm/models/util/vectorize_tour_scheduling.py +++ b/activitysim/abm/models/util/vectorize_tour_scheduling.py @@ -364,9 +364,9 @@ def tdd_interaction_dataset(tours, alts, timetable, choice_column, window_id_col Parameters ---------- - tours : pandas DataFrame + tours : pandas.DataFrame must have person_id column and index on tour_id - alts : pandas DataFrame + alts : pandas.DataFrame alts index must be timetable tdd id timetable : TimeTable object choice_column : str @@ -390,29 +390,39 @@ def tdd_interaction_dataset(tours, alts, timetable, choice_column, window_id_col tour_ids = np.repeat(tours.index, len(alts.index)) window_row_ids = np.repeat(tours[window_id_col], len(alts.index)) + chunk.log_df(trace_label, 'window_row_ids', window_row_ids) alt_tdd = alts.take(alts_ids) alt_tdd.index = tour_ids - alt_tdd[window_id_col] = window_row_ids + + import xarray as xr + alt_tdd_ = xr.Dataset.from_dataframe(alt_tdd) + dimname = alt_tdd.index.name or "index" + # alt_tdd_[window_id_col] = xr.DataArray(window_row_ids, dims=(dimname,)) + alt_tdd_[choice_column] = xr.DataArray(alts_ids, dims=(dimname,), coords=alt_tdd_.coords) # add tdd alternative id # by convention, the choice column is the first column in the interaction dataset - alt_tdd.insert(loc=0, column=choice_column, value=alts_ids) + # alt_tdd.insert(loc=0, column=choice_column, value=alts_ids) # slice out all non-available tours - available = timetable.tour_available(alt_tdd[window_id_col], alt_tdd[choice_column]) + available = timetable.tour_available(window_row_ids, alts_ids) + + del window_row_ids + chunk.log_df(trace_label, 'window_row_ids', None) + logger.debug(f"tdd_interaction_dataset keeping {available.sum()} of ({len(available)}) available alt_tdds") assert available.any() - chunk.log_df(trace_label, 'alt_tdd', alt_tdd) # catch this before we slice on available + chunk.log_df(trace_label, 'alt_tdd_', alt_tdd_) # catch this before we slice on available - alt_tdd = alt_tdd[available] + alt_tdd = alt_tdd_.isel({dimname:available}).to_dataframe() chunk.log_df(trace_label, 'alt_tdd', alt_tdd) # FIXME - don't need this any more after slicing - del alt_tdd[window_id_col] + #del alt_tdd[window_id_col] return alt_tdd diff --git a/activitysim/abm/tables/accessibility.py b/activitysim/abm/tables/accessibility.py index 5be410f1f..afc7e832e 100644 --- a/activitysim/abm/tables/accessibility.py +++ b/activitysim/abm/tables/accessibility.py @@ -29,8 +29,19 @@ def accessibility(land_use): accessibility_df = pd.DataFrame(index=land_use.index) logger.debug("created placeholder accessibility table %s" % (accessibility_df.shape,)) else: - assert accessibility_df.sort_index().index.equals(land_use.to_frame().sort_index().index), \ - f"loaded accessibility table index does not match index of land_use table" + try: + assert accessibility_df.sort_index().index.equals(land_use.to_frame().sort_index().index), \ + f"loaded accessibility table index does not match index of land_use table" + except AssertionError: + land_use_index = land_use.to_frame().index + if f"_original_{land_use_index.name}" in land_use.to_frame(): + land_use_zone_ids = land_use.to_frame()[f"_original_{land_use_index.name}"] + remapper = dict(zip(land_use_zone_ids, land_use_zone_ids.index)) + accessibility_df.index = accessibility_df.index.map(remapper.get) + assert accessibility_df.sort_index().index.equals(land_use.to_frame().sort_index().index), \ + f"loaded accessibility table index does not match index of land_use table" + else: + raise logger.info("loaded land_use %s" % (accessibility_df.shape,)) # replace table function with dataframe diff --git a/activitysim/abm/tables/households.py b/activitysim/abm/tables/households.py index 5d52ec004..a1585afc2 100644 --- a/activitysim/abm/tables/households.py +++ b/activitysim/abm/tables/households.py @@ -3,6 +3,7 @@ from builtins import range import logging +import io import pandas as pd @@ -91,6 +92,9 @@ def households(households_sample_size, override_hh_ids, trace_hh_id): df['sample_rate'] = sample_rate logger.info("loaded households %s" % (df.shape,)) + buffer = io.StringIO() + df.info(buf=buffer) + logger.debug("households.info:\n"+buffer.getvalue()) # replace table function with dataframe inject.add_table('households', df) diff --git a/activitysim/abm/tables/landuse.py b/activitysim/abm/tables/landuse.py index da0a5f863..246a25f26 100644 --- a/activitysim/abm/tables/landuse.py +++ b/activitysim/abm/tables/landuse.py @@ -1,6 +1,7 @@ # ActivitySim # See full license in LICENSE.txt. import logging +import io import pandas as pd @@ -22,6 +23,9 @@ def land_use(): df = df.sort_index() logger.info("loaded land_use %s" % (df.shape,)) + buffer = io.StringIO() + df.info(buf=buffer) + logger.debug("land_use.info:\n"+buffer.getvalue()) # replace table function with dataframe inject.add_table('land_use', df) diff --git a/activitysim/abm/tables/persons.py b/activitysim/abm/tables/persons.py index 6eb1683e7..565049f54 100644 --- a/activitysim/abm/tables/persons.py +++ b/activitysim/abm/tables/persons.py @@ -1,6 +1,7 @@ # ActivitySim # See full license in LICENSE.txt. import logging +import io import pandas as pd @@ -31,6 +32,9 @@ def persons(households, trace_hh_id): df = read_raw_persons(households) logger.info("loaded persons %s" % (df.shape,)) + buffer = io.StringIO() + df.info(buf=buffer) + logger.debug("persons.info:\n"+buffer.getvalue()) # replace table function with dataframe inject.add_table('persons', df) diff --git a/activitysim/abm/test/test_misc/test_load_cached_accessibility.py b/activitysim/abm/test/test_misc/test_load_cached_accessibility.py index 762fdd705..5af082081 100644 --- a/activitysim/abm/test/test_misc/test_load_cached_accessibility.py +++ b/activitysim/abm/test/test_misc/test_load_cached_accessibility.py @@ -85,15 +85,17 @@ def test_load_cached_accessibility(): 'initialize_households', ] - pipeline.run(models=_MODELS, resume_after=None) + try: + pipeline.run(models=_MODELS, resume_after=None) - accessibility_df = pipeline.get_table("accessibility") + accessibility_df = pipeline.get_table("accessibility") - assert 'auPkRetail' in accessibility_df + assert 'auPkRetail' in accessibility_df - pipeline.close_pipeline() - inject.clear_cache() - close_handlers() + finally: + pipeline.close_pipeline() + inject.clear_cache() + close_handlers() if __name__ == "__main__": diff --git a/activitysim/benchmarking/__init__.py b/activitysim/benchmarking/__init__.py new file mode 100644 index 000000000..ee674e1b3 --- /dev/null +++ b/activitysim/benchmarking/__init__.py @@ -0,0 +1 @@ +from . import componentwise diff --git a/activitysim/benchmarking/asv.conf.json b/activitysim/benchmarking/asv.conf.json new file mode 100644 index 000000000..47363f85b --- /dev/null +++ b/activitysim/benchmarking/asv.conf.json @@ -0,0 +1,174 @@ +{ + // The version of the config file format. Do not change, unless + // you know what you are doing. + "version": 1, + + // The name of the project being benchmarked + "project": "activitysim", + + // The project's homepage + "project_url": "https://activitysim.github.io/", + + // The URL or local path of the source code repository for the + // project being benchmarked + "repo": ".", + + // The Python project's subdirectory in your repo. If missing or + // the empty string, the project is assumed to be located at the root + // of the repository. + // "repo_subdir": "", + + // Customizable commands for building, installing, and + // uninstalling the project. See asv.conf.json documentation. + // + // "install_command": ["in-dir={env_dir} python -mpip install {wheel_file}"], + // "uninstall_command": ["return-code=any python -mpip uninstall -y {project}"], + // "build_command": [ + // "python setup.py build", + // "PIP_NO_BUILD_ISOLATION=false python -mpip wheel --no-deps --no-index -w {build_cache_dir} {build_dir}" + // ], + + // List of branches to benchmark. If not provided, defaults to "master" + // (for git) or "default" (for mercurial). + // "branches": ["master"], // for git + // "branches": ["default"], // for mercurial + + // The DVCS being used. If not set, it will be automatically + // determined from "repo" by looking at the protocol in the URL + // (if remote), or by looking for special directories, such as + // ".git" (if local). + // "dvcs": "git", + + // The tool to use to create environments. May be "conda", + // "virtualenv" or other value depending on the plugins in use. + // If missing or the empty string, the tool will be automatically + // determined by looking for tools on the PATH environment + // variable. + "environment_type": "conda", + + // timeout in seconds for installing any dependencies in environment + // defaults to 10 min + //"install_timeout": 600, + + // the base URL to show a commit for the project. + "show_commit_url": "http://github.com/ActivitySim/activitysim/commit/", + + // The Pythons you'd like to test against. If not provided, defaults + // to the current version of Python used to run `asv`. + // "pythons": ["2.7", "3.6"], + + // The list of conda channel names to be searched for benchmark + // dependency packages in the specified order + "conda_channels": ["conda-forge"], + + // The matrix of dependencies to test. Each key is the name of a + // package (in PyPI) and the values are version numbers. An empty + // list or empty string indicates to just test against the default + // (latest) version. null indicates that the package is to not be + // installed. If the package to be tested is only available from + // PyPi, and the 'environment_type' is conda, then you can preface + // the package name by 'pip+', and the package will be installed via + // pip (with all the conda available packages installed first, + // followed by the pip installed packages). + // + "matrix": { + "pyarrow": [], + "numpy": [], + "openmatrix": [], + "pandas": ["1.2"], + "pyyaml": [], + "pytables": [], + "toolz": [], + "orca": [], + "psutil": [], + "requests": [], + "numba": [], + "coverage": [], + "pytest": [], + "cytoolz": [], + "zarr": [], + "sharrow": ["pip+/Users/jeffnewman/LocalGit/sharrow_lite/dist/sharrow-2021.4-py3-none-any.whl"], + "sharrow_pro": ["pip+/Users/jeffnewman/LocalGit/sharrow_pro/dist/sharrow_pro-2021.4-py3-none-any.whl"] + }, + + // Combinations of libraries/python versions can be excluded/included + // from the set to test. Each entry is a dictionary containing additional + // key-value pairs to include/exclude. + // + // An exclude entry excludes entries where all values match. The + // values are regexps that should match the whole string. + // + // An include entry adds an environment. Only the packages listed + // are installed. The 'python' key is required. The exclude rules + // do not apply to includes. + // + // In addition to package names, the following keys are available: + // + // - python + // Python version, as in the *pythons* variable above. + // - environment_type + // Environment type, as above. + // - sys_platform + // Platform, as in sys.platform. Possible values for the common + // cases: 'linux2', 'win32', 'cygwin', 'darwin'. + // + // "exclude": [ + // {"python": "3.2", "sys_platform": "win32"}, // skip py3.2 on windows + // {"environment_type": "conda", "six": null}, // don't run without six on conda + // ], + // + // "include": [ + // // additional env for python2.7 + // {"python": "2.7", "numpy": "1.8"}, + // // additional env if run on windows+conda + // {"platform": "win32", "environment_type": "conda", "python": "2.7", "libpython": ""}, + // ], + + // The directory (relative to the current directory) that benchmarks are + // stored in. If not provided, defaults to "benchmarks" + // "benchmark_dir": "benchmarks", + + // The directory (relative to the current directory) to cache the Python + // environments in. If not provided, defaults to "env" + "env_dir": "../activitysim-asv/env", + + // The directory (relative to the current directory) that raw benchmark + // results are stored in. If not provided, defaults to "results". + "results_dir": "../activitysim-asv/results", + + // The directory (relative to the current directory) that the html tree + // should be written to. If not provided, defaults to "html". + "html_dir": "../activitysim-asv/html", + + // The number of characters to retain in the commit hashes. + // "hash_length": 8, + + // `asv` will cache results of the recent builds in each + // environment, making them faster to install next time. This is + // the number of builds to keep, per environment. + // "build_cache_size": 2, + + // The commits after which the regression search in `asv publish` + // should start looking for regressions. Dictionary whose keys are + // regexps matching to benchmark names, and values corresponding to + // the commit (exclusive) after which to start looking for + // regressions. The default is to start from the first commit + // with results. If the commit is `null`, regression detection is + // skipped for the matching benchmark. + // + // "regressions_first_commits": { + // "some_benchmark": "352cdf", // Consider regressions only after this commit + // "another_benchmark": null, // Skip regression detection altogether + // }, + + // The thresholds for relative change in results, after which `asv + // publish` starts reporting regressions. Dictionary of the same + // form as in ``regressions_first_commits``, with values + // indicating the thresholds. If multiple entries match, the + // maximum is taken. If no entry matches, the default is 5%. + // + // "regressions_thresholds": { + // "some_benchmark": 0.01, // Threshold of 1% + // "another_benchmark": 0.5, // Threshold of 50% + // }, +} diff --git a/activitysim/benchmarking/benchmarks/__init__.py b/activitysim/benchmarking/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/activitysim/benchmarking/benchmarks/mtc1full.py b/activitysim/benchmarking/benchmarks/mtc1full.py new file mode 100644 index 000000000..d6bdb85fc --- /dev/null +++ b/activitysim/benchmarking/benchmarks/mtc1full.py @@ -0,0 +1,91 @@ +from activitysim.benchmarking.componentwise import ( + template_component_timings, + template_setup_cache, +) + +EXAMPLE_NAME = "example_mtc_full" +CONFIGS_DIRS = ("configs",) +DYNAMIC_CONFIG_DIR = "bench_configs" +DATA_DIR = "data" +OUTPUT_DIR = "output" +COMPONENT_NAMES = [ + # "compute_accessibility", + "school_location", + "workplace_location", + "auto_ownership_simulate", + "free_parking", + "cdap_simulate", + "mandatory_tour_frequency", + "mandatory_tour_scheduling", + "joint_tour_frequency", + "joint_tour_composition", + "joint_tour_participation", + "joint_tour_destination", + "joint_tour_scheduling", + "non_mandatory_tour_frequency", + "non_mandatory_tour_destination", + "non_mandatory_tour_scheduling", + "tour_mode_choice_simulate", + "atwork_subtour_frequency", + "atwork_subtour_destination", + "atwork_subtour_scheduling", + "atwork_subtour_mode_choice", + "stop_frequency", + "trip_purpose", + "trip_destination", + "trip_purpose_and_destination", + "trip_scheduling", + "trip_mode_choice", + # "write_data_dictionary", + # "track_skim_usage", + "write_trip_matrices", + # "write_tables", +] +BENCHMARK_SETTINGS = { + "households_sample_size": 48_769, +} +SKIM_CACHE = False +PRELOAD_INJECTABLES = ("skim_dict",) +REPEAT = 1 +NUMBER = 1 +TIMEOUT = 36000.0 # ten hours +VERSION = "1" + + +try: + from activitysim import __data_compatability__ +except ImportError: + __data_compatability__ = None +else: + OUTPUT_DIR = f"{OUTPUT_DIR}-{__data_compatability__}" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + config_overload_dir=DYNAMIC_CONFIG_DIR, + ) + + +template_component_timings( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + (DYNAMIC_CONFIG_DIR, *CONFIGS_DIRS), + DATA_DIR, + OUTPUT_DIR, + PRELOAD_INJECTABLES, + REPEAT, + NUMBER, + TIMEOUT, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/mtc1mp4.py b/activitysim/benchmarking/benchmarks/mtc1mp4.py new file mode 100644 index 000000000..917f73049 --- /dev/null +++ b/activitysim/benchmarking/benchmarks/mtc1mp4.py @@ -0,0 +1,81 @@ +from activitysim.benchmarking.componentwise import ( + template_setup_cache, + template_component_timings_mp, +) + +import multiprocessing +import numpy as np + +PRETTY_NAME = "MTC1_MP4" +EXAMPLE_NAME = "example_mtc_full" +NUM_PROCESSORS = int(np.clip(multiprocessing.cpu_count() - 2, 2, 4)) +CONFIGS_DIRS = ("configs_mp", "configs") +DYNAMIC_CONFIG_DIR = "bench_configs_mp" +DATA_DIR = "data" +OUTPUT_DIR = "output_mp" +COMPONENT_NAMES = [ + "school_location", + "workplace_location", + "auto_ownership_simulate", + "free_parking", + "cdap_simulate", + "mandatory_tour_frequency", + "mandatory_tour_scheduling", + "joint_tour_frequency", + "joint_tour_composition", + "joint_tour_participation", + "joint_tour_destination", + "joint_tour_scheduling", + "non_mandatory_tour_frequency", + "non_mandatory_tour_destination", + "non_mandatory_tour_scheduling", + "tour_mode_choice_simulate", + "atwork_subtour_frequency", + "atwork_subtour_destination", + "atwork_subtour_scheduling", + "atwork_subtour_mode_choice", + "stop_frequency", + "trip_purpose", + "trip_destination", + "trip_purpose_and_destination", + "trip_scheduling", + "trip_mode_choice", +] +BENCHMARK_SETTINGS = { + # TODO: This multiprocess benchmarking is minimally functional, + # but has a bad habit of crashing due to memory allocation errors on + # all but the tiniest of examples. It would be great to fix the MP + # benchmarks so they use chunking, automatically configure for available + # RAM, and run a training-production cycle to get useful timing results. + "households_sample_size": 400, + "num_processes": NUM_PROCESSORS, +} +SKIM_CACHE = False +TIMEOUT = 36000.0 # ten hours +VERSION = "1" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + config_overload_dir=DYNAMIC_CONFIG_DIR, + ) + + +template_component_timings_mp( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + OUTPUT_DIR, + PRETTY_NAME, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/sandag1example.py b/activitysim/benchmarking/benchmarks/sandag1example.py new file mode 100644 index 000000000..fb5f45bf2 --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag1example.py @@ -0,0 +1,43 @@ +from activitysim.benchmarking.componentwise import ( + template_component_timings, + template_setup_cache, +) + +from .sandag_example import * + +EXAMPLE_NAME = "example_sandag_1_zone" +CONFIGS_DIRS = ("configs_1_zone", "example_mtc/configs") +DYNAMIC_CONFIG_DIR = "bench_configs" +DATA_DIR = "data_1" +OUTPUT_DIR = "output_1" +VERSION = "1" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + ) + + +template_component_timings( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + (DYNAMIC_CONFIG_DIR, *CONFIGS_DIRS), + DATA_DIR, + OUTPUT_DIR, + PRELOAD_INJECTABLES, + REPEAT, + NUMBER, + TIMEOUT, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/sandag1full.py b/activitysim/benchmarking/benchmarks/sandag1full.py new file mode 100644 index 000000000..b892d2771 --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag1full.py @@ -0,0 +1,43 @@ +from activitysim.benchmarking.componentwise import ( + template_component_timings, + template_setup_cache, +) + +from .sandag_full import * + +EXAMPLE_NAME = "example_sandag_1_zone_full" +CONFIGS_DIRS = ("configs_benchmarking", "configs_1_zone", "example_mtc/configs") +DYNAMIC_CONFIG_DIR = "bench_configs" +DATA_DIR = "data_1" +OUTPUT_DIR = "output_1" +VERSION = "1" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + ) + + +template_component_timings( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + (DYNAMIC_CONFIG_DIR, *CONFIGS_DIRS), + DATA_DIR, + OUTPUT_DIR, + PRELOAD_INJECTABLES, + REPEAT, + NUMBER, + TIMEOUT, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/sandag2example.py b/activitysim/benchmarking/benchmarks/sandag2example.py new file mode 100644 index 000000000..9903d0ec0 --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag2example.py @@ -0,0 +1,43 @@ +from activitysim.benchmarking.componentwise import ( + template_component_timings, + template_setup_cache, +) + +from .sandag_example import * + +EXAMPLE_NAME = "example_sandag_2_zone" +CONFIGS_DIRS = ("configs_2_zone", "example_psrc/configs") +DYNAMIC_CONFIG_DIR = "bench_configs" +DATA_DIR = "data_2" +OUTPUT_DIR = "output_2" +VERSION = "1" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + ) + + +template_component_timings( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + (DYNAMIC_CONFIG_DIR, *CONFIGS_DIRS), + DATA_DIR, + OUTPUT_DIR, + PRELOAD_INJECTABLES, + REPEAT, + NUMBER, + TIMEOUT, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/sandag2full.py b/activitysim/benchmarking/benchmarks/sandag2full.py new file mode 100644 index 000000000..974cb3f21 --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag2full.py @@ -0,0 +1,43 @@ +from activitysim.benchmarking.componentwise import ( + template_component_timings, + template_setup_cache, +) + +from .sandag_full import * + +EXAMPLE_NAME = "example_sandag_2_zone_full" +CONFIGS_DIRS = ("configs_benchmarking", "configs_2_zone", "example_psrc/configs") +DYNAMIC_CONFIG_DIR = "bench_configs" +DATA_DIR = "data_2" +OUTPUT_DIR = "output_2" +VERSION = "1" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + ) + + +template_component_timings( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + (DYNAMIC_CONFIG_DIR, *CONFIGS_DIRS), + DATA_DIR, + OUTPUT_DIR, + PRELOAD_INJECTABLES, + REPEAT, + NUMBER, + TIMEOUT, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/sandag3example.py b/activitysim/benchmarking/benchmarks/sandag3example.py new file mode 100644 index 000000000..aa040b144 --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag3example.py @@ -0,0 +1,43 @@ +from activitysim.benchmarking.componentwise import ( + template_component_timings, + template_setup_cache, +) + +from .sandag_example import * + +EXAMPLE_NAME = "example_sandag_3_zone" +CONFIGS_DIRS = ("configs_3_zone", "example_mtc/configs") +DYNAMIC_CONFIG_DIR = "bench_configs" +DATA_DIR = "data_3" +OUTPUT_DIR = "output_3" +VERSION = "1" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + ) + + +template_component_timings( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + (DYNAMIC_CONFIG_DIR, *CONFIGS_DIRS), + DATA_DIR, + OUTPUT_DIR, + PRELOAD_INJECTABLES, + REPEAT, + NUMBER, + TIMEOUT, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/sandag3full.py b/activitysim/benchmarking/benchmarks/sandag3full.py new file mode 100644 index 000000000..727f66a32 --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag3full.py @@ -0,0 +1,43 @@ +from activitysim.benchmarking.componentwise import ( + template_component_timings, + template_setup_cache, +) + +from .sandag_full import * + +EXAMPLE_NAME = "example_sandag_3_zone_full" +CONFIGS_DIRS = ("configs_benchmarking", "configs_3_zone", "example_mtc/configs") +DYNAMIC_CONFIG_DIR = "bench_configs" +DATA_DIR = "data_3" +OUTPUT_DIR = "output_3" +VERSION = "1" + + +def setup_cache(): + template_setup_cache( + EXAMPLE_NAME, + COMPONENT_NAMES, + BENCHMARK_SETTINGS, + dict( + read_skim_cache=SKIM_CACHE, + write_skim_cache=SKIM_CACHE, + ), + CONFIGS_DIRS, + DATA_DIR, + OUTPUT_DIR, + ) + + +template_component_timings( + globals(), + COMPONENT_NAMES, + EXAMPLE_NAME, + (DYNAMIC_CONFIG_DIR, *CONFIGS_DIRS), + DATA_DIR, + OUTPUT_DIR, + PRELOAD_INJECTABLES, + REPEAT, + NUMBER, + TIMEOUT, + VERSION, +) diff --git a/activitysim/benchmarking/benchmarks/sandag_example.py b/activitysim/benchmarking/benchmarks/sandag_example.py new file mode 100644 index 000000000..0ba16a9ba --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag_example.py @@ -0,0 +1,42 @@ +COMPONENT_NAMES = [ + # "compute_accessibility", + "school_location", + "workplace_location", + "auto_ownership_simulate", + "free_parking", + "cdap_simulate", + "mandatory_tour_frequency", + "mandatory_tour_scheduling", + "joint_tour_frequency", + "joint_tour_composition", + "joint_tour_participation", + "joint_tour_destination", + "joint_tour_scheduling", + "non_mandatory_tour_frequency", + "non_mandatory_tour_destination", + "non_mandatory_tour_scheduling", + "tour_mode_choice_simulate", + "atwork_subtour_frequency", + "atwork_subtour_destination", + "atwork_subtour_scheduling", + "atwork_subtour_mode_choice", + "stop_frequency", + "trip_purpose", + "trip_destination", + "trip_purpose_and_destination", + "trip_scheduling", + "trip_mode_choice", + # "write_data_dictionary", + # "track_skim_usage", + "write_trip_matrices", + # "write_tables", +] +BENCHMARK_SETTINGS = { + "households_sample_size": 48_769, + "sharrow": True, +} +SKIM_CACHE = True +PRELOAD_INJECTABLES = ("skim_dict",) +REPEAT = 1 +NUMBER = 1 +TIMEOUT = 36000.0 # ten hours diff --git a/activitysim/benchmarking/benchmarks/sandag_full.py b/activitysim/benchmarking/benchmarks/sandag_full.py new file mode 100644 index 000000000..51710a1af --- /dev/null +++ b/activitysim/benchmarking/benchmarks/sandag_full.py @@ -0,0 +1,41 @@ +COMPONENT_NAMES = [ + # "compute_accessibility", + "school_location", + "workplace_location", + "auto_ownership_simulate", + "free_parking", + "cdap_simulate", + "mandatory_tour_frequency", + "mandatory_tour_scheduling", + "joint_tour_frequency", + "joint_tour_composition", + "joint_tour_participation", + "joint_tour_destination", + "joint_tour_scheduling", + "non_mandatory_tour_frequency", + "non_mandatory_tour_destination", + "non_mandatory_tour_scheduling", + "tour_mode_choice_simulate", + "atwork_subtour_frequency", + "atwork_subtour_destination", + "atwork_subtour_scheduling", + "atwork_subtour_mode_choice", + "stop_frequency", + "trip_purpose", + "trip_destination", + "trip_purpose_and_destination", + "trip_scheduling", + "trip_mode_choice", + # "write_data_dictionary", + # "track_skim_usage", + "write_trip_matrices", + # "write_tables", +] +BENCHMARK_SETTINGS = { + "households_sample_size": 48_769, # match hh sample size in example data +} +SKIM_CACHE = False +PRELOAD_INJECTABLES = ("skim_dict",) +REPEAT = 1 +NUMBER = 1 +TIMEOUT = 36000.0 # ten hours diff --git a/activitysim/benchmarking/componentwise.py b/activitysim/benchmarking/componentwise.py new file mode 100644 index 000000000..3d6a9a2b3 --- /dev/null +++ b/activitysim/benchmarking/componentwise.py @@ -0,0 +1,691 @@ +import glob +import os +import logging +import logging.handlers +import numpy as np +import pandas as pd +import yaml +import traceback + +from ..cli.create import get_example +from ..core.pipeline import open_pipeline, run_model +from ..core import inject, tracing +from ..cli.run import config, pipeline, INJECTABLES +from . import workspace + +logger = logging.getLogger(__name__) + + +def reload_settings(settings_filename, **kwargs): + settings = config.read_settings_file(settings_filename, mandatory=True) + for k in kwargs: + settings[k] = kwargs[k] + inject.add_injectable("settings", settings) + return settings + + +def component_logging(component_name): + root_logger = logging.getLogger() + + CLOG_FMT = "%(asctime)s %(levelname)7s - %(name)s: %(message)s" + + logfilename = config.log_file_path(f"asv-{component_name}.log") + + # avoid creation of multiple file handlers for logging components + # as we will re-enter this function for every component run + for entry in root_logger.handlers: + if (isinstance(entry, logging.handlers.RotatingFileHandler)) and ( + entry.formatter._fmt == CLOG_FMT + ): + return + + tracing.config_logger(basic=True) + file_handler = logging.handlers.RotatingFileHandler( + filename=logfilename, + mode="a", + maxBytes=50_000_000, + backupCount=5, + ) + formatter = logging.Formatter( + fmt=CLOG_FMT, + datefmt="%Y-%m-%d %H:%M:%S", + ) + file_handler.setFormatter(formatter) + logging.getLogger().addHandler(file_handler) + + +def setup_component( + component_name, + working_dir=".", + preload_injectables=(), + configs_dirs=("configs"), + data_dir="data", + output_dir="output", + settings_filename="settings.yaml", + **other_settings, +): + """ + Prepare to benchmark a model component. + + This function sets up everything, opens the pipeline, and + reloads table state from checkpoints of prior components. + All this happens here, before the model component itself + is actually executed inside the timed portion of the loop. + """ + if isinstance(configs_dirs, str): + configs_dirs = [configs_dirs] + inject.add_injectable( + "configs_dir", [os.path.join(working_dir, i) for i in configs_dirs] + ) + inject.add_injectable("data_dir", os.path.join(working_dir, data_dir)) + inject.add_injectable("output_dir", os.path.join(working_dir, output_dir)) + + reload_settings( + settings_filename, + benchmarking=component_name, + checkpoints=False, + **other_settings, + ) + + component_logging(component_name) + logger.info("connected to component logger") + config.filter_warnings() + logging.captureWarnings(capture=True) + + # register abm steps and other abm-specific injectables outside of + # benchmark timing loop + if not inject.is_injectable("preload_injectables"): + logger.info("preload_injectables yes import") + from activitysim import abm + else: + logger.info("preload_injectables no import") + + # Extract the resume_after argument based on the model immediately + # prior to the component being benchmarked. + models = config.setting("models") + try: + component_index = models.index(component_name) + except ValueError: + # the last component to be benchmarked isn't included in the + # pre-checkpointed model list, we just resume from the end + component_index = len(models) + if component_index: + resume_after = models[component_index - 1] + else: + resume_after = None + + if config.setting("multiprocess", False): + raise NotImplementedError( + "multiprocess component benchmarking is not yet implemented" + ) + # Component level timings for multiprocess benchmarking + # are not generated using this code that re-runs individual + # components. Instead, those benchmarks are generated in + # aggregate during setup and then extracted from logs later. + else: + open_pipeline(resume_after, mode="r") + + for k in preload_injectables: + if inject.get_injectable(k, None) is not None: + logger.info("pre-loaded %s", k) + + # Directories Logging + for k in ["configs_dir", "settings_file_name", "data_dir", "output_dir"]: + logger.info(f"DIRECTORY {k}: {inject.get_injectable(k, None)}") + + # Settings Logging + log_settings = [ + "checkpoints", + "chunk_training_mode", + "chunk_size", + "chunk_method", + "trace_hh_id", + "households_sample_size", + "check_for_variability", + "use_shadow_pricing", + "want_dest_choice_sample_tables", + "log_alt_losers", + "sharrow", + ] + for k in log_settings: + logger.info(f"SETTING {k}: {config.setting(k)}") + + logger.info("setup_component completed: %s", component_name) + + +def run_component(component_name): + logger.info("run_component: %s", component_name) + try: + if config.setting("multiprocess", False): + raise NotImplementedError( + "multiprocess component benchmarking is not yet implemented" + ) + # Component level timings for multiprocess benchmarking + # are not generated using this code that re-runs individual + # components. Instead, those benchmarks are generated in + # aggregate during setup and then extracted from logs later. + else: + run_model(component_name) + except Exception as err: + logger.exception("run_component exception: %s", component_name) + raise + else: + logger.info("run_component completed: %s", component_name) + return 0 + + +def teardown_component(component_name): + logger.info("teardown_component: %s", component_name) + + # use the pipeline module to clear out all the orca tables, so + # the next benchmark run has a clean slate. + # anything needed should be reloaded from the pipeline checkpoint file + pipeline_tables = pipeline.registered_tables() + for table_name in pipeline_tables: + logger.info("dropping table %s", table_name) + pipeline.drop_table(table_name) + + if config.setting("multiprocess", False): + raise NotImplementedError("multiprocess benchmarking is not yet implemented") + else: + pipeline.close_pipeline() + logger.critical( + "teardown_component completed: %s\n\n%s\n\n", component_name, "~" * 88 + ) + return 0 + + +def pre_run( + model_working_dir, + configs_dirs=None, + data_dir="data", + output_dir="output", + settings_file_name=None, +): + """ + Pre-run the models, checkpointing everything. + + By checkpointing everything, it is possible to run each benchmark + by recreating the state of the pipeline immediately prior to that + component. + + Parameters + ---------- + model_working_dir : str + Path to the model working directory, generally inside the + benchmarking workspace. + configs_dirs : Iterable[str], optional + Override the config dirs, similar to using -c on the command line + for a model run. + data_dir : str, optional + Override the data directory similar to using -d on the command line + for a model run. + output_dir : str, optional + Override the output directory similar to using -o on the command line + for a model run. + settings_file_name : str, optional + Override the settings file name, similar to using -s on the command line + for a model run. + """ + if configs_dirs is None: + inject.add_injectable("configs_dir", os.path.join(model_working_dir, "configs")) + else: + configs_dirs_ = [os.path.join(model_working_dir, i) for i in configs_dirs] + inject.add_injectable("configs_dir", configs_dirs_) + inject.add_injectable("data_dir", os.path.join(model_working_dir, data_dir)) + inject.add_injectable("output_dir", os.path.join(model_working_dir, output_dir)) + + if settings_file_name is not None: + inject.add_injectable("settings_file_name", settings_file_name) + + # Always pre_run from the beginning + config.override_setting("resume_after", None) + + # register abm steps and other abm-specific injectables + if not inject.is_injectable("preload_injectables"): + from activitysim import ( + abm, + ) # register abm steps and other abm-specific injectables + + if settings_file_name is not None: + inject.add_injectable("settings_file_name", settings_file_name) + + # cleanup + # cleanup_output_files() + + tracing.config_logger(basic=False) + config.filter_warnings() + logging.captureWarnings(capture=True) + + # directories + for k in ["configs_dir", "settings_file_name", "data_dir", "output_dir"]: + logger.info("SETTING %s: %s" % (k, inject.get_injectable(k, None))) + + log_settings = inject.get_injectable("log_settings", {}) + for k in log_settings: + logger.info("SETTING %s: %s" % (k, config.setting(k))) + + # OMP_NUM_THREADS: openmp + # OPENBLAS_NUM_THREADS: openblas + # MKL_NUM_THREADS: mkl + for env in ["MKL_NUM_THREADS", "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS"]: + logger.info(f"ENV {env}: {os.getenv(env)}") + + np_info_keys = [ + "atlas_blas_info", + "atlas_blas_threads_info", + "atlas_info", + "atlas_threads_info", + "blas_info", + "blas_mkl_info", + "blas_opt_info", + "lapack_info", + "lapack_mkl_info", + "lapack_opt_info", + "mkl_info", + ] + + for cfg_key in np_info_keys: + info = np.__config__.get_info(cfg_key) + if info: + for info_key in ["libraries"]: + if info_key in info: + logger.info(f"NUMPY {cfg_key} {info_key}: {info[info_key]}") + + t0 = tracing.print_elapsed_time() + + logger.info(f"MODELS: {config.setting('models')}") + + if config.setting("multiprocess", False): + logger.info("run multi-process complete simulation") + else: + logger.info("run single process simulation") + pipeline.run(models=config.setting("models")) + pipeline.close_pipeline() + + tracing.print_elapsed_time("prerun required models for checkpointing", t0) + + return 0 + + +def run_multiprocess(): + logger.info("run multiprocess simulation") + tracing.delete_trace_files() + tracing.delete_output_files("h5") + tracing.delete_output_files("csv") + tracing.delete_output_files("txt") + tracing.delete_output_files("yaml") + tracing.delete_output_files("prof") + tracing.delete_output_files("omx") + + from activitysim.core import mp_tasks + + injectables = {k: inject.get_injectable(k) for k in INJECTABLES} + mp_tasks.run_multiprocess(injectables) + + assert not pipeline.is_open() + + if config.setting("cleanup_pipeline_after_run", False): + pipeline.cleanup_pipeline() + + +######## + + +def local_dir(): + benchmarking_directory = workspace.get_dir() + if benchmarking_directory is not None: + return benchmarking_directory + return os.getcwd() + + +def model_dir(*subdirs): + return os.path.join(local_dir(), "models", *subdirs) + + +def template_setup_cache( + example_name, + component_names, + benchmark_settings, + benchmark_network_los, + config_dirs=("configs",), + data_dir="data", + output_dir="output", + settings_filename="settings.yaml", + skip_component_names=None, + config_overload_dir="dynamic_configs", +): + """ + + Parameters + ---------- + example_name : str + The name of the example to benchmark, as used in + the `activitysim create` command. + component_names : Sequence + The names of the model components to be individually + benchmarked. This list does not need to include all + the components usually included in the example. + benchmark_settings : Mapping + Settings values to override from their usual values + in the example. + benchmark_network_los : Mapping + Network LOS values to override from their usual values + in the example. + config_dirs : Sequence + data_dir : str + output_dir : str + settings_filename : str + skip_component_names : Sequence, optional + Skip running these components when setting up the + benchmarks (i.e. in pre-run). + config_overload_dir : str, default 'dynamic_configs' + """ + try: + os.makedirs(model_dir(), exist_ok=True) + get_example( + example_name=example_name, + destination=model_dir(), + benchmarking=True, + ) + os.makedirs(model_dir(example_name, config_overload_dir), exist_ok=True) + + # Find the settings file and extract the complete set of models included + from ..core.config import read_settings_file + + try: + existing_settings, settings_filenames = read_settings_file( + settings_filename, + mandatory=True, + include_stack=True, + configs_dir_list=[model_dir(example_name, c) for c in config_dirs], + ) + except Exception: + logger.error(f"os.getcwd:{os.getcwd()}") + raise + if "models" not in existing_settings: + raise ValueError( + f"missing list of models from {config_dirs}/{settings_filename}" + ) + models = existing_settings["models"] + use_multiprocess = existing_settings.get("multiprocess", False) + for k in existing_settings: + print(f"existing_settings {k}:", existing_settings[k]) + + settings_changes = dict( + benchmarking=True, + checkpoints=True, + trace_hh_id=None, + chunk_training_mode="disabled", + inherit_settings=True, + ) + settings_changes.update(benchmark_settings) + + # Pre-run checkpointing or Multiprocess timing runs only need to + # include models up to the penultimate component to be benchmarked. + last_component_to_benchmark = 0 + for cname in component_names: + try: + last_component_to_benchmark = max( + models.index(cname), last_component_to_benchmark + ) + except ValueError: + if cname not in models: + logger.warning( + f"want to benchmark {example_name}.{cname} but it is not in the list of models to run" + ) + else: + raise + if use_multiprocess: + last_component_to_benchmark += 1 + pre_run_model_list = models[:last_component_to_benchmark] + if skip_component_names is not None: + for cname in skip_component_names: + if cname in pre_run_model_list: + pre_run_model_list.remove(cname) + settings_changes["models"] = pre_run_model_list + + if "multiprocess_steps" in existing_settings: + multiprocess_steps = existing_settings["multiprocess_steps"] + while ( + multiprocess_steps[-1].get("begin", "missing-begin") + not in pre_run_model_list + ): + multiprocess_steps = multiprocess_steps[:-1] + if len(multiprocess_steps) == 0: + break + settings_changes["multiprocess_steps"] = multiprocess_steps + + with open( + model_dir(example_name, config_overload_dir, settings_filename), "wt" + ) as yf: + try: + yaml.safe_dump(settings_changes, yf) + except Exception: + logger.error(f"settings_changes:{str(settings_changes)}") + logger.exception("oops") + raise + with open( + model_dir(example_name, config_overload_dir, "network_los.yaml"), "wt" + ) as yf: + benchmark_network_los["inherit_settings"] = True + yaml.safe_dump(benchmark_network_los, yf) + + os.makedirs(model_dir(example_name, output_dir), exist_ok=True) + + # Running the model through all the steps and checkpointing everywhere is + # expensive and only needs to be run once. Once it is done we will write + # out a completion token file to indicate to future benchmark attempts + # that this does not need to be repeated. Developers should manually + # delete the token (or the whole model file) when a structural change + # in the model happens such that re-checkpointing is needed (this should + # happen rarely). + use_config_dirs = (config_overload_dir, *config_dirs) + token_file = model_dir(example_name, output_dir, "benchmark-setup-token.txt") + if not os.path.exists(token_file) and not use_multiprocess: + try: + pre_run( + model_dir(example_name), + use_config_dirs, + data_dir, + output_dir, + settings_filename, + ) + except Exception as err: + with open( + model_dir(example_name, output_dir, "benchmark-setup-error.txt"), + "wt", + ) as f: + f.write(f"error {err}") + f.write(traceback.format_exc()) + raise + else: + with open(token_file, "wt") as f: + # We write the commit into the token, in case that is useful + # to developers to decide if the checkpointed pipeline is + # out of date. + asv_commit = os.environ.get("ASV_COMMIT", "ASV_COMMIT_UNKNOWN") + f.write(asv_commit) + if use_multiprocess: + # Multiprocessing timing runs are actually fully completed within + # the setup_cache step, and component-level timings are written out + # to log files by activitysim during this run. + asv_commit = os.environ.get("ASV_COMMIT", "ASV_COMMIT_UNKNOWN") + try: + pre_run( + model_dir(example_name), + use_config_dirs, + data_dir, + output_dir, + settings_filename, + ) + run_multiprocess() + except Exception as err: + with open( + model_dir( + example_name, output_dir, f"-mp-run-error-{asv_commit}.txt" + ), + "wt", + ) as f: + f.write(f"error {err}") + f.write(traceback.format_exc()) + raise + + except Exception as err: + logger.error( + f"error in template_setup_cache({example_name}):\n" + traceback.format_exc() + ) + raise + + +def template_component_timings( + module_globals, + component_names, + example_name, + config_dirs, + data_dir, + output_dir, + preload_injectables, + repeat_=(1, 20, 10.0), # min_repeat, max_repeat, max_time_seconds + number_=1, + timeout_=36000.0, # ten hours, + version_="1", +): + """ + Inject ComponentTiming classes into a module namespace for benchmarking a model. + + Arguments with a trailing underscore get passed through to airspeed velocity, see + https://asv.readthedocs.io/en/stable/benchmarks.html?highlight=repeat#timing-benchmarks + for more info on these. + + Parameters + ---------- + module_globals : Mapping + The module globals namespace, into which the timing classes are written. + component_names : Iterable[str] + Names of components to benchmark. + example_name : str + Name of the example model being benchmarked, as it appears in the + exammple_manifest.yaml file. + config_dirs : Tuple[str] + Config directories to use when running the model being benchmarked. + data_dir, output_dir : str + Data and output directories to use when running the model being + benchmarked. + preload_injectables : Tuple[str] + Names of injectables to pre-load (typically skims). + repeat_ : tuple + The values for (min_repeat, max_repeat, max_time_seconds). See ASV docs + for more information. + number_ : int, default 1 + The number of iterations in each sample. Generally this should stay + set to 1 for ActivitySim timing. + timeout_ : number, default 36000.0, + How many seconds before the benchmark is assumed to have crashed. The + typical default for airspeed velocity is 60 but that is wayyyyy too short for + ActivitySim, so the default here is set to ten hours. + version_ : str + Used to determine when to invalidate old benchmark results. Benchmark results + produced with a different value of the version than the current value will be + ignored. + """ + + for componentname in component_names: + + class ComponentTiming: + component_name = componentname + warmup_time = 0 + min_run_count = 1 + processes = 1 + repeat = repeat_ + number = number_ + timeout = timeout_ + # params = [True, False] + # param_names = ['sharrow',] + + def setup(self): + setup_component( + self.component_name, + model_dir(example_name), + preload_injectables, + config_dirs, + data_dir, + output_dir, + sharrow=True, + ) + + def teardown(self): + teardown_component(self.component_name) + + def time_component(self): + run_component(self.component_name) + + time_component.pretty_name = f"{example_name}:{componentname}" + time_component.version = version_ + + ComponentTiming.__name__ = f"{componentname}" + + module_globals[componentname] = ComponentTiming + + +def template_component_timings_mp( + module_globals, + component_names, + example_name, + output_dir, + pretty_name, + version_="1", +): + """ + Inject ComponentTiming classes into a module namespace for benchmarking a model. + + This "MP" version for multiprocessing doesn't actually measure the time taken, + but instead it parses the run logs from a single full run of the mode, to + extract the per-component timings. Most of the configurability has been removed + compared to the single-process version of this function. + + Parameters + ---------- + module_globals : Mapping + The module globals namespace, into which the timing classes are written. + component_names : Iterable[str] + Names of components to benchmark. + example_name : str + Name of the example model being benchmarked, as it appears in the + exammple_manifest.yaml file. + output_dir : str + Output directory to use when running the model being benchmarked. + pretty_name : str + A "pretty" name for this set of benchmarks. + version_ : str + Used to determine when to invalidate old benchmark results. Benchmark results + produced with a different value of the version than the current value will be + ignored. + """ + + for componentname in component_names: + + class ComponentTiming: + component_name = componentname + + def track_component(self): + durations = [] + inject.add_injectable("output_dir", model_dir(example_name, output_dir)) + logfiler = config.log_file_path(f"timing_log.mp_households_*.csv") + for logfile in glob.glob(logfiler): + df = pd.read_csv(logfile) + dfq = df.query(f"component_name=='{self.component_name}'") + if len(dfq): + durations.append(dfq.iloc[-1].duration) + if len(durations): + return np.mean(durations) + else: + raise ValueError("no results available") + + track_component.pretty_name = f"{pretty_name}:{componentname}" + track_component.version = version_ + track_component.unit = "s" + + ComponentTiming.__name__ = f"{componentname}" + + module_globals[componentname] = ComponentTiming diff --git a/activitysim/benchmarking/instrument.py b/activitysim/benchmarking/instrument.py new file mode 100644 index 000000000..b6574e8a4 --- /dev/null +++ b/activitysim/benchmarking/instrument.py @@ -0,0 +1,27 @@ +import os +from pyinstrument import Profiler +import importlib +import webbrowser + + +def run_instrument(bench_name, component_name, out_file=None): + bench_module = importlib.import_module( + f"activitysim.benchmarking.benchmarks.{bench_name}" + ) + + component = getattr(bench_module, component_name)() + component.setup() + with Profiler() as profiler: + component.time_component() + component.teardown() + + if out_file is None: + out_file = f"instrument/{bench_name}/{component_name}.html" + dirname = os.path.dirname(out_file) + if dirname: + os.makedirs(dirname, exist_ok=True) + + if out_file: + with open(out_file, "wt") as f: + f.write(profiler.output_html()) + webbrowser.open(f"file://{os.path.realpath(out_file)}") diff --git a/activitysim/benchmarking/latest.py b/activitysim/benchmarking/latest.py new file mode 100644 index 000000000..a8b02e8ca --- /dev/null +++ b/activitysim/benchmarking/latest.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function, unicode_literals + +import logging +import subprocess +import traceback +import shlex +from asv.console import log +from asv import util +from asv.commands.run import Run +from asv.commands import common_args + + +def _do_build(args): + env, conf, repo, commit_hash = args + try: + with log.set_level(logging.WARN): + env.install_project(conf, repo, commit_hash) + except util.ProcessError: + return (env.name, False) + return (env.name, True) + + +def _do_build_multiprocess(args): + """ + multiprocessing callback to build the project in one particular + environment. + """ + try: + return _do_build(args) + except BaseException as exc: + raise util.ParallelFailure(str(exc), exc.__class__, traceback.format_exc()) + + +class Latest(Run): + @classmethod + def setup_arguments(cls, subparsers): + parser = subparsers.add_parser( + "latest", + help="Run a benchmark suite on the HEAD commit", + description="Run a benchmark suite.", + ) + + common_args.add_bench(parser) + parser.add_argument( + "--profile", + "-p", + action="store_true", + help="""In addition to timing, run the benchmarks through + the `cProfile` profiler and store the results.""", + ) + common_args.add_parallel(parser) + common_args.add_show_stderr(parser) + parser.add_argument( + "--quick", + "-q", + action="store_true", + help="""Do a "quick" run, where each benchmark function is + run only once. This is useful to find basic errors in the + benchmark functions faster. The results are unlikely to + be useful, and thus are not saved.""", + ) + common_args.add_environment(parser) + parser.add_argument( + "--set-commit-hash", + default=None, + help="""Set the commit hash to use when recording benchmark + results. This makes results to be saved also when using an + existing environment.""", + ) + common_args.add_launch_method(parser) + parser.add_argument( + "--dry-run", + "-n", + action="store_true", + default=None, + help="""Do not save any results to disk.""", + ) + common_args.add_machine(parser) + parser.add_argument( + "--skip-existing-successful", + action="store_true", + help="""Skip running benchmarks that have previous successful + results""", + ) + parser.add_argument( + "--skip-existing-failed", + action="store_true", + help="""Skip running benchmarks that have previous failed + results""", + ) + parser.add_argument( + "--skip-existing-commits", + action="store_true", + help="""Skip running benchmarks for commits that have existing + results""", + ) + parser.add_argument( + "--skip-existing", + "-k", + action="store_true", + help="""Skip running benchmarks that have previous successful + or failed results""", + ) + parser.add_argument( + "--interleave-processes", + action="store_true", + default=False, + help="""Interleave benchmarks with multiple processes across + commits. This can avoid measurement biases from commit ordering, + can take longer.""", + ) + parser.add_argument( + "--no-interleave-processes", + action="store_false", + dest="interleave_processes", + ) + parser.add_argument( + "--no-pull", action="store_true", help="Do not pull the repository" + ) + + parser.set_defaults(func=cls.run_from_args) + + return parser + + @classmethod + def run_from_conf_args(cls, conf, args, **kwargs): + return cls.run( + conf=conf, + range_spec="HEAD^!", + steps=None, + bench=args.bench, + attribute=args.attribute, + parallel=args.parallel, + show_stderr=args.show_stderr, + quick=args.quick, + profile=args.profile, + env_spec=args.env_spec, + set_commit_hash=args.set_commit_hash, + dry_run=args.dry_run, + machine=args.machine, + skip_successful=args.skip_existing_successful or args.skip_existing, + skip_failed=args.skip_existing_failed or args.skip_existing, + skip_existing_commits=args.skip_existing_commits, + record_samples=True, + append_samples=True, + pull=not args.no_pull, + interleave_processes=args.interleave_processes, + launch_method=args.launch_method, + **kwargs + ) + + +class Batch(Run): + @classmethod + def setup_arguments(cls, subparsers): + parser = subparsers.add_parser( + "batch", + help="Run a set of benchmark suites based on a batch file. " + "Simply give the file name, which should be a text file " + "containing a number of activitysim benchmark commands.", + description="Run a set of benchmark suites based on a batch file.", + ) + + parser.add_argument( + "file", + action="store", + type=str, + help="""Set the file name to use for reading multiple commands.""", + ) + + parser.set_defaults(func=cls.run_from_args) + + return parser + + @classmethod + def run_from_conf_args(cls, conf, args, **kwargs): + with open(args.file, "rt") as f: + for line in f.readlines(): + subprocess.run(["activitysim", "benchmark", *shlex.split(line)]) diff --git a/activitysim/benchmarking/profile_inspector.py b/activitysim/benchmarking/profile_inspector.py new file mode 100644 index 000000000..84e954984 --- /dev/null +++ b/activitysim/benchmarking/profile_inspector.py @@ -0,0 +1,106 @@ +import base64 +import contextlib +import io +import json +import os +import tempfile +import zlib +import traceback +from asv.plugins.snakeviz import SnakevizGui +from asv.commands import Command +from asv.console import log + + +def benchmark_snakeviz(json_record, benchmark=None): + """ + A utility to directly display saved profiling data in Snakeviz. + + Parameters + ---------- + json_record : Path-like + The archived json file that contains profile data for benchmarks. + benchmark : str, optional + The name of the benchmark to display. + """ + from asv import util + + with open(json_record, "rt") as f: + json_content = json.load(f) + profiles = json_content.get("profiles", {}) + if benchmark is None or benchmark not in profiles: + if profiles: + log.info("\n\nAvailable profiles:") + for k in profiles.keys(): + log.info(f"- {k}") + else: + log.info(f"\n\nNo profiles stored in {json_record}") + if benchmark is None: + return + raise KeyError() + profile_data = zlib.decompress(base64.b64decode(profiles[benchmark].encode())) + prefix = benchmark.replace(".", "__") + "." + with temp_profile(profile_data, prefix) as profile_path: + log.info(f"Profiling data cached to {profile_path}") + import pstats + + prof = pstats.Stats(profile_path) + prof.strip_dirs().dump_stats(profile_path + "b") + try: + SnakevizGui.open_profiler_gui(profile_path + "b") + except KeyboardInterrupt: + pass + except Exception: + + traceback.print_exc() + input(input("Press Enter to continue...")) + finally: + os.remove(profile_path + "b") + + +@contextlib.contextmanager +def temp_profile(profile_data, prefix=None): + profile_fd, profile_path = tempfile.mkstemp(prefix=prefix) + try: + with io.open(profile_fd, "wb", closefd=True) as fd: + fd.write(profile_data) + + yield profile_path + finally: + os.remove(profile_path) + + +class ProfileInspector(Command): + @classmethod + def setup_arguments(cls, subparsers): + parser = subparsers.add_parser( + "snakeviz", + help="""Run snakeviz on a particular benchmark that has been profiled.""", + description="Inspect a benchmark profile", + ) + + parser.add_argument( + "json_record", + help="""The json file in the benchmark results to read profile data from.""", + ) + + parser.add_argument( + "benchmark", + help="""The benchmark to profile. Must be a + fully-specified benchmark name. For parameterized benchmark, it + must include the parameter combination to use, e.g.: + benchmark_name(param0, param1, ...)""", + default=None, + nargs="?", + ) + + parser.set_defaults(func=cls.run_from_args) + + return parser + + @classmethod + def run_from_conf_args(cls, conf, args, **kwargs): + return cls.run(json_record=args.json_record, benchmark=args.benchmark) + + @classmethod + def run(cls, json_record, benchmark): + benchmark_snakeviz(json_record, benchmark) diff --git a/activitysim/benchmarking/reader.py b/activitysim/benchmarking/reader.py new file mode 100644 index 000000000..f1da72d70 --- /dev/null +++ b/activitysim/benchmarking/reader.py @@ -0,0 +1,28 @@ +import pandas as pd +import yaml + + +def read_results(json_file): + """ + Read benchmarking results from a single commit on a single machine. + + Parameters + ---------- + json_file : str + Path to the json file containing the target results. + + Returns + ------- + pandas.DataFrame + """ + out_data = {} + with open(json_file, "rt") as f: + in_data = yaml.safe_load(f) + for k, v in in_data["results"].items(): + if v is None: + continue + m, c, _ = k.split(".") + if m not in out_data: + out_data[m] = {} + out_data[m][c] = v["result"][0] + return pd.DataFrame(out_data) diff --git a/activitysim/benchmarking/workspace.py b/activitysim/benchmarking/workspace.py new file mode 100644 index 000000000..691c4aa31 --- /dev/null +++ b/activitysim/benchmarking/workspace.py @@ -0,0 +1,20 @@ +import os + +_directory = None + + +def get_dir(): + global _directory + if _directory is None: + _directory = os.environ.get("ASIM_ASV_WORKSPACE", None) + if _directory is None: + _directory = os.environ.get("ASV_CONF_DIR", None) + return _directory + + +def set_dir(directory): + global _directory + if directory: + _directory = directory + else: + _directory = os.environ.get("ASIM_ASV_WORKSPACE", None) diff --git a/activitysim/cli/benchmark.py b/activitysim/cli/benchmark.py new file mode 100644 index 000000000..7eaeeeccc --- /dev/null +++ b/activitysim/cli/benchmark.py @@ -0,0 +1,297 @@ +import os +import sys +import json +import shutil +import subprocess + +ASV_CONFIG = { + # The version of the config file format. Do not change, unless + # you know what you are doing. + "version": 1, + # The name of the project being benchmarked + "project": "activitysim", + # The project's homepage + "project_url": "https://activitysim.github.io/", + # The URL or local path of the source code repository for the + # project being benchmarked + "repo": ".", + # The tool to use to create environments. + "environment_type": "conda", + # the base URL to show a commit for the project. + "show_commit_url": "http://github.com/ActivitySim/activitysim/commit/", + # The Pythons you'd like to test against. If not provided, defaults + # to the current version of Python used to run `asv`. + # "pythons": ["2.7", "3.6"], + # The list of conda channel names to be searched for benchmark + # dependency packages in the specified order + "conda_channels": ["conda-forge"], + # The matrix of dependencies to test. Each key is the name of a + # package (in PyPI) and the values are version numbers. An empty + # list or empty string indicates to just test against the default + # (latest) version. null indicates that the package is to not be + # installed. If the package to be tested is only available from + # PyPi, and the 'environment_type' is conda, then you can preface + # the package name by 'pip+', and the package will be installed via + # pip (with all the conda available packages installed first, + # followed by the pip installed packages). + "matrix": { + "pyarrow": [], + "numpy": [], + "scipy": ["1.7"], + "openmatrix": [], + "pandas": ["1.3"], + "pyyaml": [], + "pytables": [], + "toolz": [], + "orca": [], + "psutil": [], + "requests": [], + "numba": ["0.54"], + "cytoolz": [], + "zarr": [], + "xarray": [], + "filelock": [], + "dask": [], + "networkx": [], + # "sharrow": [], + }, + # The directory (relative to the current directory) to cache the Python + # environments in. If not provided, defaults to "env" + # "env_dir": "../activitysim-asv/env", + # The directory (relative to the current directory) that raw benchmark + # results are stored in. If not provided, defaults to "results". + # "results_dir": "../activitysim-asv/results", + # The directory (relative to the current directory) that the html tree + # should be written to. If not provided, defaults to "html". + # "html_dir": "../activitysim-asv/html", + # List of branches to benchmark. If not provided, defaults to "master" + # (for git) or "default" (for mercurial). + "branches": ["develop"], +} + + +def make_asv_argparser(parser): + """ + The entry point for asv. + + Most of this work is handed off to the airspeed velocity library. + """ + try: + from asv.commands import common_args, Command, util, command_order + except ImportError: + return + + def help(args): + parser.print_help() + sys.exit(0) + + common_args.add_global_arguments(parser, suppress_defaults=False) + + subparsers = parser.add_subparsers( + title="benchmarking with airspeed velocity", description="valid subcommands" + ) + + help_parser = subparsers.add_parser("help", help="Display usage information") + help_parser.set_defaults(afunc=help) + + commands = dict((x.__name__, x) for x in util.iter_subclasses(Command)) + + hide_commands = [ + "quickstart", + ] + + for command in command_order: + if str(command) in hide_commands: + continue + subparser = commands[str(command)].setup_arguments(subparsers) + common_args.add_global_arguments(subparser) + subparser.add_argument( + "--workspace", + "-w", + help="benchmarking workspace directory", + default=".", + ) + subparser.add_argument( + '--branch', + type=str, + action='append', + metavar='NAME', + help='git branch to include in benchmarking' + ) + del commands[command] + + for name, command in sorted(commands.items()): + if str(command) in hide_commands: + continue + subparser = command.setup_arguments(subparsers) + subparser.add_argument( + "--workspace", + "-w", + help="benchmarking workspace directory", + default=".", + ) + subparser.add_argument( + '--branch', + type=str, + action='append', + metavar='NAME', + help='git branch to include in benchmarking' + ) + common_args.add_global_arguments(subparser) + + from ..benchmarking.latest import Latest, Batch + + subparser = Latest.setup_arguments(subparsers) + subparser.add_argument( + "--workspace", + "-w", + help="benchmarking workspace directory", + default=".", + ) + subparser.add_argument( + '--branch', + type=str, + action='append', + metavar='NAME', + help='git branch to include in benchmarking' + ) + common_args.add_global_arguments(subparser) + + subparser = Batch.setup_arguments(subparsers) + subparser.add_argument( + "--workspace", + "-w", + help="benchmarking workspace directory", + default=".", + ) + + from ..benchmarking.profile_inspector import ProfileInspector + + subparser = ProfileInspector.setup_arguments(subparsers) + subparser.add_argument( + "--workspace", + "-w", + help="benchmarking workspace directory", + default=".", + ) + + parser.set_defaults(afunc=benchmark) + return parser, subparsers + + +def benchmark(args): + try: + import asv + except ModuleNotFoundError: + print("airspeed velocity is not installed") + print("try `conda install asv -c conda-forge` if you want to run benchmarks") + sys.exit(1) + from asv.console import log + from asv import util + + log.enable(args.verbose) + + log.info("<== benchmarking activitysim ==>") + + # workspace + args.workspace = os.path.abspath(args.workspace) + + if os.path.abspath(os.path.expanduser("~")) == args.workspace: + log.error( + "don't run benchmarks in the user's home directory \n" + "try changing directories before calling `activitysim benchmark` " + "or use the --workspace option \n" + ) + sys.exit(1) + + if not os.path.isdir(args.workspace): + raise NotADirectoryError(args.workspace) + log.info(f" workspace: {args.workspace}") + os.chdir(args.workspace) + os.environ["ASIM_ASV_WORKSPACE"] = str(args.workspace) + + from ..benchmarking import workspace + + workspace.set_dir(args.workspace) + + from .. import __path__ as pkg_path + + log.info(f" activitysim installation: {pkg_path[0]}") + + repo_dir = os.path.normpath(os.path.join(pkg_path[0], "..")) + git_dir = os.path.normpath(os.path.join(repo_dir, ".git")) + local_git = os.path.exists(git_dir) + log.info(f" local git repo available: {local_git}") + + branches = args.branch + + asv_config = ASV_CONFIG.copy() + if local_git: + repo_dir_rel = os.path.relpath(repo_dir, args.workspace) + log.info(f" local git repo: {repo_dir_rel}") + asv_config["repo"] = repo_dir_rel + if not branches: + # add current branch to the branches to benchmark + current_branch = subprocess.check_output( + ['git', 'branch', '--show-current'], + env={'GIT_DIR': git_dir}, + stdin=None, stderr=None, + shell=False, + universal_newlines=False, + ).decode().strip() + if current_branch: + asv_config["branches"].append(current_branch) + else: + log.info(f" local git repo available: {local_git}") + asv_config["repo"] = "https://github.com/ActivitySim/activitysim.git" + + asv_config["branches"].extend(branches) + + # copy the benchmarks to the workspace, deleting previous files in workspace + import activitysim.benchmarking.benchmarks + + benchmarks_dir = os.path.dirname(activitysim.benchmarking.benchmarks.__file__) + shutil.rmtree( + os.path.join(args.workspace, "benchmarks"), + ignore_errors=True, + ) + shutil.copytree( + benchmarks_dir, + os.path.join(args.workspace, "benchmarks"), + dirs_exist_ok=True, + ) + + # write the asv config to the workspace + conf_file = os.path.normpath(os.path.join(args.workspace, "asv.conf.json")) + with open(conf_file, "wt") as jf: + json.dump(asv_config, jf) + + if args.config and args.config != "asv.conf.json": + raise ValueError( + "activitysim manages the asv config json file itself, do not use --config" + ) + args.config = os.path.abspath(conf_file) + + # write the pre-commit search and replace hook to the workspace + search_replace_file = os.path.normpath( + os.path.join(args.workspace, ".pre-commit-search-and-replace.yaml") + ) + with open(search_replace_file, "wt") as sf: + benchpath = os.path.join(args.workspace, "benchmarks") + if not benchpath.endswith(os.path.sep): + benchpath += os.path.sep + benchpath = benchpath.replace(os.path.sep, r"[/\\]") + sf.write(f"""- search: /{benchpath}/\n replacement: ./\n""") + + try: + result = args.func(args) + except util.UserError as e: + log.error(str(e)) + sys.exit(1) + finally: + log.flush() + + if result is None: + result = 0 + + sys.exit(result) diff --git a/activitysim/cli/cli.py b/activitysim/cli/cli.py index 1eff88406..5756490db 100644 --- a/activitysim/cli/cli.py +++ b/activitysim/cli/cli.py @@ -12,7 +12,7 @@ def __init__(self, version, description): version=self.version) # print help if no subcommand is provided - self.parser.set_defaults(func=lambda x: self.parser.print_help()) + self.parser.set_defaults(afunc=lambda x: self.parser.print_help()) self.subparsers = self.parser.add_subparsers(title='subcommands', help='available subcommand options') @@ -20,8 +20,8 @@ def __init__(self, version, description): def add_subcommand(self, name, args_func, exec_func, description): subparser = self.subparsers.add_parser(name, description=description) args_func(subparser) - subparser.set_defaults(func=exec_func) + subparser.set_defaults(afunc=exec_func) def execute(self): args = self.parser.parse_args() - args.func(args) + args.afunc(args) diff --git a/activitysim/cli/create.py b/activitysim/cli/create.py index b9e0fe33d..66c749be9 100644 --- a/activitysim/cli/create.py +++ b/activitysim/cli/create.py @@ -81,7 +81,7 @@ def list_examples(): return ret -def get_example(example_name, destination): +def get_example(example_name, destination, benchmarking=False, optimize=True): """ Copy project data to user-specified directory. @@ -101,6 +101,8 @@ def get_example(example_name, destination): If the target directory already exists, project files will be copied into a subdirectory with the same name as the example + benchmarking: bool + optimize: bool """ if example_name not in EXAMPLES: sys.exit(f"error: could not find example '{example_name}'") @@ -111,8 +113,11 @@ def get_example(example_name, destination): dest_path = destination example = EXAMPLES[example_name] + itemlist = example.get('include', []) + if benchmarking: + itemlist.extend(example.get('benchmarking', [])) - for item in example.get('include', []): + for item in itemlist: # split include string into source/destination paths items = item.split() @@ -136,6 +141,18 @@ def get_example(example_name, destination): print(f'copied! new project files are in {os.path.abspath(dest_path)}') + if optimize: + optimize_func_names = example.get('optimize', None) + if isinstance(optimize_func_names, str): + optimize_func_names = [optimize_func_names] + if optimize_func_names: + from ..examples import optimize_example_data + for optimize_func_name in optimize_func_names: + getattr( + optimize_example_data, + optimize_func_name, + )(os.path.abspath(dest_path)) + instructions = example.get('instructions') if instructions: print(instructions) @@ -149,11 +166,18 @@ def copy_asset(asset_path, target_path, dirs_exist_ok=False): shutil.copytree(asset_path, target_path, dirs_exist_ok=dirs_exist_ok) else: + target_dir = os.path.dirname(target_path) + if target_dir: + os.makedirs(target_dir, exist_ok=True) shutil.copy(asset_path, target_path) def download_asset(url, target_path, sha256=None): os.makedirs(os.path.dirname(target_path), exist_ok=True) + if url.endswith(".gz") and not target_path.endswith(".gz"): + target_path_dl = target_path + ".gz" + else: + target_path_dl = target_path if sha256 and os.path.isfile(target_path): computed_sha256 = sha256_checksum(target_path) if sha256 == computed_sha256: @@ -167,9 +191,15 @@ def download_asset(url, target_path, sha256=None): print(f'downloading {os.path.basename(target_path)} ...') with requests.get(url, stream=True) as r: r.raise_for_status() - with open(target_path, 'wb') as f: + with open(target_path_dl, 'wb') as f: for chunk in r.iter_content(chunk_size=None): f.write(chunk) + if target_path_dl != target_path: + import gzip + with gzip.open(target_path_dl, 'rb') as f_in: + with open(target_path, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + os.remove(target_path_dl) computed_sha256 = sha256_checksum(target_path) if sha256 and sha256 != computed_sha256: raise ValueError( diff --git a/activitysim/cli/main.py b/activitysim/cli/main.py index 113e8b9d6..323122c00 100644 --- a/activitysim/cli/main.py +++ b/activitysim/cli/main.py @@ -1,13 +1,25 @@ import sys +import os -from activitysim.cli import CLI -from activitysim.cli import run -from activitysim.cli import create -from activitysim import __version__, __doc__ +def main(): + # set all these before we import numpy or any other math library + if len(sys.argv) > 1 and sys.argv[1] == "benchmark": + os.environ['MKL_NUM_THREADS'] = '1' + os.environ['OMP_NUM_THREADS'] = '1' + os.environ['OPENBLAS_NUM_THREADS'] = '1' + os.environ['NUMBA_NUM_THREADS'] = '1' + os.environ['VECLIB_MAXIMUM_THREADS'] = '1' + os.environ['NUMEXPR_NUM_THREADS'] = '1' + + from activitysim.cli import CLI + from activitysim.cli import run + from activitysim.cli import create + from activitysim.cli import benchmark + + from activitysim import __version__, __doc__ -def main(): asim = CLI(version=__version__, description=__doc__) asim.add_subcommand(name='run', @@ -18,4 +30,8 @@ def main(): args_func=create.add_create_args, exec_func=create.create, description=create.create.__doc__) + asim.add_subcommand(name='benchmark', + args_func=benchmark.make_asv_argparser, + exec_func=benchmark.benchmark, + description=benchmark.benchmark.__doc__) sys.exit(asim.execute()) diff --git a/activitysim/cli/run.py b/activitysim/cli/run.py index c4a2ffb14..ca70c7a98 100644 --- a/activitysim/cli/run.py +++ b/activitysim/cli/run.py @@ -58,6 +58,13 @@ def add_run_args(parser, multiprocess=True): type=int, metavar='BYTES', help='chunk size') + parser.add_argument('--chunk_training_mode', + type=str, + help='chunk training mode, one of [training, adaptive, production, disabled]') + parser.add_argument('--households_sample_size', + type=int, + metavar='N', + help='households sample size') if multiprocess: parser.add_argument('-m', '--multiprocess', @@ -77,7 +84,7 @@ def validate_injectable(name): except RuntimeError: # injectable is missing, meaning is hasn't been explicitly set # and defaults cannot be found. - sys.exit('Error: please specify either a --working_dir ' + sys.exit(f'Error({name}): please specify either a --working_dir ' "containing 'configs', 'data', and 'output' folders " 'or all three of --config, --data, and --output') @@ -130,6 +137,10 @@ def inject_arg(name, value, cache=False): if args.chunk_size: config.override_setting('chunk_size', int(args.chunk_size)) + if args.chunk_training_mode is not None: + config.override_setting('chunk_training_mode', args.chunk_training_mode) + if args.households_sample_size is not None: + config.override_setting('households_sample_size', args.households_sample_size) for injectable in ['configs_dir', 'data_dir', 'output_dir']: validate_injectable(injectable) @@ -174,6 +185,9 @@ def run(args): tracing.config_logger(basic=True) handle_standard_args(args) # possibly update injectables + if config.setting("rotate_logs", False): + config.rotate_log_directory() + # legacy support for run_list setting nested 'models' and 'resume_after' settings if config.setting('run_list'): warnings.warn("Support for 'run_list' settings group will be removed.\n" @@ -275,6 +289,9 @@ def run(args): chunk.consolidate_logs() mem.consolidate_logs() + from ..core.flow import TimeLogger + TimeLogger.aggregate_summary(logger) + tracing.print_elapsed_time('all models', t0) return 0 diff --git a/activitysim/core/assign.py b/activitysim/core/assign.py index b764d37cc..acb2ac4af 100644 --- a/activitysim/core/assign.py +++ b/activitysim/core/assign.py @@ -1,5 +1,6 @@ # ActivitySim # See full license in LICENSE.txt. +import pickle from builtins import zip from builtins import object @@ -255,6 +256,36 @@ def to_series(x): variables = OrderedDict() temps = OrderedDict() + # draw required randomness in one slug + # TODO: generalize to all randomness, not just lognormals + n_randoms = 0 + for expression_idx in assignment_expressions.index: + expression = assignment_expressions.loc[expression_idx,'expression'] + if 'rng.lognormal_for_df(df,' in expression: + expression = expression.replace('rng.lognormal_for_df(df,', f'rng_lognormal(random_draws[{n_randoms}],') + n_randoms += 1 + assignment_expressions.loc[expression_idx, 'expression'] = expression + if n_randoms: + from activitysim.core import pipeline + try: + random_draws = pipeline.get_rn_generator().normal_for_df(df, broadcast=True, size=n_randoms) + except RuntimeError: + pass + else: + _locals_dict['random_draws'] = random_draws + + def rng_lognormal(random_draws, mu, sigma, broadcast=True, scale=False): + if scale: + x = 1 + ((sigma * sigma) / (mu * mu)) + mu = np.log(mu / (np.sqrt(x))) + sigma = np.sqrt(np.log(x)) + assert broadcast + return np.exp(random_draws*sigma + mu) + + _locals_dict['rng_lognormal'] = rng_lognormal + + sharrow_enabled = config.setting("sharrow", False) + # need to be able to identify which variables causes an error, which keeps # this from being expressed more parsimoniously @@ -269,7 +300,7 @@ def to_series(x): logger.warning("assign_variables target obscures local_d name '%s'", str(target)) if trace_label: - logger.debug(f"{trace_label}.assign_variables {target} = {expression}") + logger.info(f"{trace_label}.assign_variables {target} = {expression}") if is_temp_singular(target) or is_throwaway(target): try: @@ -296,7 +327,29 @@ def to_series(x): # FIXME should whitelist globals for security? globals_dict = {} - expr_values = to_series(eval(expression, globals_dict, _locals_dict)) + try: + expr_values = to_series(eval(expression, globals_dict, _locals_dict)) + except ValueError: + import os + import uuid + uid = uuid.uuid1() + # for k in globals_dict.keys(): + # try: + # with open(os.path.join(config.get_cache_dir(), f"dump-{uid}-g-{k}.pkl"), 'wb') as f: + # pickle.dump(globals_dict[k], f) + # except Exception as err: + # logger.error(repr(err)) + # for k in _locals_dict.keys(): + # try: + # with open(os.path.join(config.get_cache_dir(), f"dump-{uid}-l-{k}.pkl"), 'wb') as f: + # pickle.dump(_locals_dict[k], f) + # except Exception as err: + # logger.error(repr(err)) + raise + + if sharrow_enabled and np.issubdtype(expr_values.dtype, np.floating) and expr_values.dtype.itemsize < 4: + # promote to float32, sharrow is not compatible with float less than 32 + expr_values = expr_values.astype(np.float32) np.seterr(**save_err) np.seterrcall(saved_handler) diff --git a/activitysim/core/choosing.py b/activitysim/core/choosing.py new file mode 100644 index 000000000..8f0fe525f --- /dev/null +++ b/activitysim/core/choosing.py @@ -0,0 +1,89 @@ + +import numpy as np +import pandas as pd +from numba import njit + +@njit +def choice_maker(pr, rn, out=None): + if out is None: + out = np.empty(pr.shape[0], dtype=np.int32) + n_alts = pr.shape[1] + for row in range(pr.shape[0]): + z = rn[row] + for col in range(n_alts): + z = z - pr[row, col] + if z <= 0: + out[row] = col + break + else: + # rare condition, only if a random point is greater than 1 (a bug) + # or if the sum of probabilities is less than 1 and a random point + # is greater than that sum, which due to the limits of numerical + # precision can technically happen + max_pr = 0.0 + for col in range(n_alts): + if pr[row, col] > max_pr: + out[row] = col + max_pr = pr[row, col] + return out + + +@njit +def sample_choices_maker( + prob_array, + random_array, + alts_array, + out_choices=None, + out_choice_probs=None, +): + """ + Random sample of alternatives. + + Parameters + ---------- + prob_array : array of float, shape (n_choosers, n_alts) + random_array : array of float, shape (n_choosers, n_samples) + alts_array : array of int, shape (n_alts) + out_choices : array of int, shape (n_samples, n_choosers), optional + out_choice_probs : array of float, shape (n_samples, n_choosers), optional + + Returns + ------- + out_choices, out_choice_probs + """ + n_choosers = random_array.shape[0] + sample_size = random_array.shape[1] + n_alts = prob_array.shape[1] + if out_choices is None: + out_choices = np.empty((sample_size, n_choosers), dtype=np.int32) + if out_choice_probs is None: + out_choice_probs = np.empty((sample_size, n_choosers), dtype=np.float32) + + for c in range(n_choosers): + random_points = np.sort(random_array[c, :]) + a = 0 + s = 0 + z = 0.0 + for a in range(n_alts): + z += prob_array[c, a] + while s < sample_size and z > random_points[s]: + out_choices[s, c] = alts_array[a] + out_choice_probs[s, c] = prob_array[c, a] + s += 1 + if s >= sample_size: + break + if s < sample_size: + # rare condition, only if a random point is greater than 1 (a bug) + # or if the sum of probabilities is less than 1 and a random point + # is greater than that sum, which due to the limits of numerical + # precision can technically happen + a = n_alts-1 + while prob_array[c, a] < 1e-30 and a > 0: + # slip back to the last choice with non-trivial prob + a -= 1 + while s < sample_size: + out_choices[s, c] = alts_array[a] + out_choice_probs[s, c] = prob_array[c, a] + s += 1 + + return out_choices, out_choice_probs \ No newline at end of file diff --git a/activitysim/core/chunk.py b/activitysim/core/chunk.py index a209b34e8..149c30d9b 100644 --- a/activitysim/core/chunk.py +++ b/activitysim/core/chunk.py @@ -12,6 +12,7 @@ import numpy as np import pandas as pd +import xarray as xr from . import config from . import mem @@ -470,10 +471,10 @@ def size_it(df): elif isinstance(df, pd.DataFrame): elements = util.iprod(df.shape) bytes = 0 if not elements else df.memory_usage(index=True).sum() - elif isinstance(df, np.ndarray): + elif isinstance(df, (np.ndarray, xr.DataArray)): elements = util.iprod(df.shape) bytes = df.nbytes - elif isinstance(df, list): + elif isinstance(df, (list, tuple)): # dict of series, dataframe, or ndarray (e.g. assign assign_variables target and temp dicts) elements = 0 bytes = 0 @@ -481,7 +482,7 @@ def size_it(df): e, b = size_it(v) elements += e bytes += b - elif isinstance(df, dict): + elif isinstance(df, (dict, xr.Dataset)): # dict of series, dataframe, or ndarray (e.g. assign assign_variables target and temp dicts) elements = 0 bytes = 0 @@ -512,6 +513,8 @@ def size_it(df): shape = f"list({[x.shape for x in df]})" elif isinstance(df, dict): shape = f"dict({[v.shape for v in df.values()]})" + elif isinstance(df, xr.Dataset): + shape = df.dims else: shape = df.shape diff --git a/activitysim/core/cleaning.py b/activitysim/core/cleaning.py new file mode 100644 index 000000000..e69de29bb diff --git a/activitysim/core/config.py b/activitysim/core/config.py index b85a02e81..797156fb2 100644 --- a/activitysim/core/config.py +++ b/activitysim/core/config.py @@ -6,6 +6,7 @@ import yaml import sys import warnings +import time import logging from activitysim.core import inject @@ -237,18 +238,19 @@ def cascading_input_file_path(file_name, dir_list_injectable_name, mandatory=Tru dir_paths = [dir_paths] if isinstance(dir_paths, str) else dir_paths file_path = None - for dir in dir_paths: - p = os.path.join(dir, file_name) - if os.path.isfile(p): - file_path = p - break + if file_name is not None: + for dir in dir_paths: + p = os.path.join(dir, file_name) + if os.path.isfile(p): + file_path = p + break - if allow_glob and len(glob.glob(p)) > 0: - file_path = p - break + if allow_glob and len(glob.glob(p)) > 0: + file_path = p + break if mandatory and not file_path: - raise RuntimeError("file_path %s: file '%s' not in %s" % + raise FileNotFoundError("file_path %s: file '%s' not in %s" % (dir_list_injectable_name, file_name, dir_paths)) return file_path @@ -315,6 +317,21 @@ def output_file_path(file_name): return build_output_file_path(file_name, use_prefix=prefix) +def profiling_file_path(file_name): + + profile_dir = inject.get_injectable('profile_dir', None) + if profile_dir is None: + output_dir = inject.get_injectable('output_dir') + profile_dir = os.path.join( + output_dir, + time.strftime("profiling--%Y-%m-%d--%H-%M-%S") + ) + os.makedirs(profile_dir, exist_ok=True) + inject.add_injectable('profile_dir', profile_dir) + + return os.path.join(profile_dir, file_name) + + def trace_file_path(file_name): output_dir = inject.get_injectable('output_dir') @@ -333,6 +350,12 @@ def log_file_path(file_name, prefix=True): output_dir = inject.get_injectable('output_dir') + # - check if running asv and if so, log to commit-specific subfolder + asv_commit = os.environ.get('ASV_COMMIT', None) + if asv_commit: + output_dir = os.path.join(output_dir, f'log-{asv_commit}') + os.makedirs(output_dir, exist_ok=True) + # - check for optional log subfolder if os.path.exists(os.path.join(output_dir, 'log')): output_dir = os.path.join(output_dir, 'log') @@ -362,6 +385,30 @@ def open_log_file(file_name, mode, header=None, prefix=False): return f +def rotate_log_directory(): + + output_dir = inject.get_injectable('output_dir') + log_dir = os.path.join(output_dir, 'log') + if not os.path.exists(log_dir): + return + + from datetime import datetime + from stat import ST_CTIME + old_log_time = os.stat(log_dir)[ST_CTIME] + rotate_name = os.path.join( + output_dir, + datetime.fromtimestamp(old_log_time).strftime("log--%Y-%m-%d--%H-%M-%S") + ) + try: + os.rename(log_dir, rotate_name) + except Exception as err: + # if Windows fights us due to permissions or whatever, + print(f"unable to rotate log file, {err!r}") + else: + # on successful rotate, create new empty log directory + os.makedirs(log_dir) + + def pipeline_file_path(file_name): prefix = inject.get_injectable('pipeline_file_prefix', None) @@ -377,7 +424,7 @@ def __str__(self): return repr(f"Settings file '{self.file_name}' not found in {self.configs_dir}") -def read_settings_file(file_name, mandatory=True, include_stack=[], configs_dir_list=None): +def read_settings_file(file_name, mandatory=True, include_stack=False, configs_dir_list=None): """ look for first occurence of yaml file named in directories in configs_dir list, @@ -396,7 +443,7 @@ def read_settings_file(file_name, mandatory=True, include_stack=[], configs_dir_ file_name mandatory: booelan if true, raise SettingsFileNotFound exception if no settings file, otherwise return empty dict - include_stack: boolean + include_stack: boolean or list only used for recursive calls to provide list of files included so far to detect cycles Returns: dict @@ -422,7 +469,10 @@ def backfill_settings(settings, backfill): inheriting = False settings = {} - source_file_paths = include_stack.copy() + if isinstance(include_stack, list): + source_file_paths = include_stack.copy() + else: + source_file_paths = [] for dir in configs_dir_list: file_path = os.path.join(dir, file_name) if os.path.exists(file_path): @@ -480,7 +530,6 @@ def backfill_settings(settings, backfill): assert os.path.join(dir, inherit_file_name) not in source_file_paths, \ f"circular inheritance of {inherit_file_name}: {source_file_paths}: " # make a recursive call to switch inheritance chain to specified file - configs_dir_list = None logger.debug("inheriting additional settings for %s from %s" % (file_name, inherit_file_name)) s, source_file_paths = \ diff --git a/activitysim/core/configuration.py b/activitysim/core/configuration.py new file mode 100644 index 000000000..9ccf8c145 --- /dev/null +++ b/activitysim/core/configuration.py @@ -0,0 +1,263 @@ +import os +import yaml + +NO_DEFAULT = "< NO DEFAULT >" + + +class IsA: + """ + Decorator for a class attribute that validates type and optionally provides defaults. + """ + + def __init__( + self, + *required_types, + coerce=False, + default=NO_DEFAULT, + doc=None, + type_descrip=None, + ): + """ + Decorator for class attribute that validates type and optional defaults + + Parameters + ---------- + required_types : Tuple[type] + coerce : bool, default False + Whether to coerce input values to the correct type, as possible. + default : Any, optional + A default value that will be used for this attribute. If not + provided, then a value must be assigned explictly to this + class attribute or an exception is raised. + doc : str + Docstring for class attribute. + """ + assert len(required_types) + self.required_types = required_types + self.coerce = coerce + self.default = default + self.type_descrip = type_descrip or ", ".join( + str(getattr(i, "__name__", i)) for i in required_types + ) + if doc: + if "\n" in doc: + self.__doc__ = doc + else: + self.__doc__ = f"{self.type_descrip}: {doc}" + + def __set_name__(self, owner, name): + # self : IsA + # owner : parent class that will have `self` as a member + # name : the name of the attribute that `self` will be + self.public_name = name + self.private_name = '_' + name + + def __get__(self, obj, objtype=None): + # self : IsA + # obj : instance of parent class that has `self` as a member + # objtype : class of `obj` + if obj is None: + return self + v = getattr(obj, self.private_name, self.default) + if v == NO_DEFAULT: + try: + f = obj._frozen + except AttributeError: + pass + else: + if f: + raise ValueError(f"{self.public_name!r} is not set and has no default") + return v + + def __set__(self, obj, value): + # self : IsA + # obj : instance of parent class that has `self` as a member + # value : the new value that is trying to be assigned + if value is not None and not isinstance(value, self.required_types): + if self.coerce: + try: + value = self.required_types[0](value) + except Exception as err: + raise AttributeError(f"for {self.public_name!r} can't coerce {type(value)}") from err + else: + raise AttributeError(f"can't set {self.public_name!r} with {type(value)}, must be {self.type_descrip}") + setattr(obj, self.private_name, value) + + def __delete__(self, obj): + # self : IsA + # obj : instance of parent class that has `self` as a member + delattr(obj, self.private_name) + + def validate(self, obj): + pass + +class IsPath(IsA): + + def __init__( + self, + *, + coerce=True, + default=NO_DEFAULT, + doc=None, + create=False, + exists=False, + isdir=False, + ): + super(IsPath, self).__init__(str, coerce=coerce, default=default, doc=doc) + self._create = create + self._exists = exists + self._isdir = isdir + + def __set__(self, obj, value): + # self : Path + # obj : instance of parent class that has `self` as a member + # value : the new value that is trying to be assigned + if value is not None and not isinstance(value, self.required_types): + if self.coerce: + try: + value = self.required_types[0](value) + except Exception as err: + raise AttributeError(f"for {self.public_name} can't coerce {type(value)}") from err + else: + raise AttributeError(f"can't set {self.public_name} with {type(value)}") + if self._exists and not os.path.exists(value): + raise FileNotFoundError(value) + if self._isdir and not os.path.isdir(value): + raise NotADirectoryError(value) + setattr(obj, self.private_name, value) + + +class IsSubconfig(IsA): + + def __init__( + self, + *required_types, + coerce=True, + default=NO_DEFAULT, + doc=None, + ): + super().__init__(dict, *required_types, coerce=coerce, default=default, doc=doc) + + def __set__(self, obj, value): + # self : Path + # obj : instance of parent class that has `self` as a member + # value : the new value that is trying to be assigned + if value is not None and not isinstance(value, self.required_types[1:]): + if self.coerce: + try: + value = self.required_types[1](**value) + except Exception as err: + raise AttributeError(f"for {self.public_name} can't coerce {type(value)}") from err + else: + raise AttributeError(f"can't set {self.public_name} with {type(value)}") + setattr(obj, self.private_name, value) + + +class Configuration: + + def __setattr__(self, name, value): + if name[0]=="_" and name[-1] != "_": + super().__setattr__(name, value) + else: + if not hasattr(self, name): + try: + frozen = self._frozen + except AttributeError: + pass + else: + if frozen: + raise ValueError(f"cannot set attribute {name!r}") + super().__setattr__(name, value) + + def __init__(self, **kwargs): + self._frozen = False + for k, v in kwargs.items(): + setattr(self, k, v) + self._frozen = True + for k in dir(self): + if k[:2] == "__" and k[-2:] == "__": + continue + try: + setattr(self, k, getattr(self, k)) + except: + raise #TypeError(f"missing required keyword {k!r}") + + def __getitem__(self, item): + return getattr(self, item) + + def __setitem__(self, key, value): + setattr(self, key, value) + + def __repr__(self): + return f"<{self.__class__.__name__}>" + + @classmethod + def load(cls, *filenames, **kwargs): + """ + Read one or more yaml files, aggregating the results. + + At the top level, all files must be mappings, or all lists. + + Parameters + ---------- + *filenames : str + Path names for files to load. + **kwargs + Other keyword arguments are passed to Dict.load() and are common + across all loaded files. + + Returns + ------- + Dict or List + """ + if len(filenames) == 0: + raise ValueError("must give at least one filename") + staged = [] + + + + + + for filename in filenames: + with open(filename, 'r', encoding=encoding) as f: + try: + content = yaml.safe_load(f) + if isinstance(content, Mapping): + staged.append(content) + else: + raise ValueError(f"error in reading {filename!r}") + except Exception as err: + from io import StringIO + buffer = StringIO() + err_logger = lambda x: buffer.write(f"{x}\n") + yaml_check(filename, logger=err_logger) + raise ValueError(buffer.getvalue()) from err + + result = staged[0] + for s in staged[1:]: + result.update(s) + return result + + +class _SubConfigurationDemo(Configuration): + key1 = IsA(str) + key2 = IsA(int) + + +class _ConfigurationDemo(Configuration): + + data_dir = IsPath( + default="/tmp", + doc="Path to data directory.", + exists=True, + ) + + output_dir = IsPath( + doc="Path to model outputs directory.", + create=True, + ) + + ii = IsA(int, default=1) + ff = IsA(float, default=0.0) + kk = IsSubconfig(_SubConfigurationDemo) + diff --git a/activitysim/core/expressions.py b/activitysim/core/expressions.py index 05e0b8eb1..9bceb20fd 100644 --- a/activitysim/core/expressions.py +++ b/activitysim/core/expressions.py @@ -85,10 +85,10 @@ def compute_columns(df, model_settings, locals_dict={}, trace_label=None): _locals_dict.update(tables) # FIXME a number of asim model preprocessors want skim_dict - should they request it in model_settings.TABLES? - _locals_dict.update({ - # 'los': inject.get_injectable('network_los', None), - 'skim_dict': inject.get_injectable('skim_dict', None), - }) + if config.setting("sharrow", False): + _locals_dict['skim_dict'] = inject.get_injectable('skim_dataset_dict', None) + else: + _locals_dict['skim_dict'] = inject.get_injectable('skim_dict', None) results, trace_results, trace_assigned_locals \ = assign.assign_variables(expressions_spec, diff --git a/activitysim/core/fast_mapping.py b/activitysim/core/fast_mapping.py new file mode 100644 index 000000000..44f1c335f --- /dev/null +++ b/activitysim/core/fast_mapping.py @@ -0,0 +1,56 @@ +import numba as nb +import numpy as np +import pandas as pd + + +@nb.njit +def _fast_map(fm, target, dtype=np.int32): + out = np.zeros(len(target), dtype=dtype) + for n in range(target.size): + out[n] = fm[target[n]] + return out + + +class FastMapping: + + def __init__(self, source, to_range=np.int64): + if isinstance(source, pd.Series): + m = nb.typed.Dict.empty( + key_type=nb.from_dtype(source.index.dtype), + value_type=nb.from_dtype(source.dtype), + ) + for k, v in source.items(): + m[k] = v + self._in_dtype = source.index.dtype + self._out_dtype = source.dtype + self._mapper = m + elif to_range: + m = nb.typed.Dict.empty( + key_type=nb.from_dtype(source.dtype), + value_type=nb.from_dtype(to_range), + ) + for v, k in enumerate(source): + m[k] = v + self._in_dtype = source.dtype + self._out_dtype = to_range + self._mapper = m + else: + raise ValueError("invalid input") + + def __len__(self): + return len(self._mapper) + + def __contains__(self, item): + return item in self._mapper + + def __getitem__(self, item): + return self._mapper[item] + + def apply_to(self, target): + if isinstance(target, pd.Series): + return pd.Series( + _fast_map(self._mapper, target.astype(self._in_dtype).to_numpy(), dtype=self._out_dtype), + index=target.index, + ) + return _fast_map(self._mapper, np.asarray(target, dtype=self._in_dtype), dtype=self._out_dtype) + diff --git a/activitysim/core/flow.py b/activitysim/core/flow.py new file mode 100644 index 000000000..1fb7cb99f --- /dev/null +++ b/activitysim/core/flow.py @@ -0,0 +1,636 @@ +import os +import hashlib +import re +import glob +import numpy as np +import pandas as pd +import time +import logging +import openmatrix +import contextlib +from orca import orca +from numbers import Number +from typing import Mapping +from datetime import timedelta +from stat import ST_MTIME + +try: + import sharrow as sh +except ModuleNotFoundError: + sh = None + +from .simulate_consts import SPEC_EXPRESSION_NAME, SPEC_LABEL_NAME +from . import inject, config +from .. import __version__ + +logger = logging.getLogger(__name__) + +_FLOWS = {} + + +@contextlib.contextmanager +def logtime(tag, tag2=''): + logger.info(f"begin {tag} {tag2}") + t0 = time.time() + try: + yield + except: + logger.error(f"error in {tag} after {timedelta(seconds=time.time()-t0)} {tag2}") + raise + else: + logger.info(f"completed {tag} in {timedelta(seconds=time.time()-t0)} {tag2}") + + +class TimeLogger: + + aggregate_timing = {} + + def __init__(self, tag1): + self._time_point = self._time_start = time.time() + self._time_log = [] + self._tag1 = tag1 + + def mark(self, tag, ping=True, logger=None, suffix=""): + if ping: + now = time.time() + elapsed = now - self._time_point + self._time_log.append((tag, timedelta(seconds=elapsed))) + self._time_point = now + if logger is not None: + logger.info("elapsed time {0} {1} {2}".format( + tag, timedelta(seconds=elapsed), suffix, + )) + else: + self._time_log.append((tag, "skipped")) + elapsed = 0 + if self._tag1: + tag = f"{self._tag1}.{tag}" + if tag not in self.aggregate_timing: + self.aggregate_timing[tag] = elapsed + else: + self.aggregate_timing[tag] += elapsed + + def summary(self, logger, tag, level=20, suffix=None): + gross_elaspsed = time.time() - self._time_start + if suffix: + msg = f"{tag} in {timedelta(seconds=gross_elaspsed)}: ({suffix})\n" + else: + msg = f"{tag} in {timedelta(seconds=gross_elaspsed)}: \n" + msgs = [] + for i in self._time_log: + j = timedelta(seconds=self.aggregate_timing[f"{self._tag1}.{i[0]}"]) + msgs.append(" - {0:24s} {1} [{2}]".format(*i, j)) + msg += "\n".join(msgs) + logger.log(level=level, msg=msg) + + @classmethod + def aggregate_summary(cls, logger, heading="Aggregate Flow Timing Summary", level=20): + msg = f"{heading}\n" + msgs = [] + for tag, elapsed in cls.aggregate_timing.items(): + msgs.append(" - {0:48s} {1}".format(tag, timedelta(seconds=elapsed))) + msg += "\n".join(msgs) + logger.log(level=level, msg=msg) + + +def only_numbers(x, exclude_keys=()): + """ + All the values in a dict that are plain numbers. + """ + y = {} + for k, v in x.items(): + if k not in exclude_keys: + if isinstance(v, Number): + y[k] = v + # elif isinstance(v, np.ndarray): + # y[k] = v + return y + + +def get_flow(spec, local_d, trace_label=None, choosers=None, interacts=None): + global _FLOWS + extra_vars = only_numbers(local_d) + orig_col_name = local_d.get('orig_col_name', None) + dest_col_name = local_d.get('dest_col_name', None) + stop_col_name = None + timeframe = local_d.get('timeframe', 'tour') + if timeframe == 'trip': + orig_col_name = local_d.get('ORIGIN', orig_col_name) + dest_col_name = local_d.get('DESTINATION', dest_col_name) + if orig_col_name is None and 'od_skims' in local_d: + orig_col_name = local_d['od_skims'].orig_key + if dest_col_name is None and 'od_skims' in local_d: + dest_col_name = local_d['od_skims'].dest_key + if stop_col_name is None and 'dp_skims' in local_d: + stop_col_name = local_d['dp_skims'].dest_key + local_d = size_terms_on_flow(local_d) + size_term_mapping = local_d.get('size_array', {}) + flow = new_flow( + spec, extra_vars, + orig_col_name, + dest_col_name, + trace_label, + timeframe=timeframe, + choosers=choosers, + stop_col_name=stop_col_name, + size_term_mapping=size_term_mapping, + interacts=interacts, + ) + return flow + + +def should_invalidate_cache_file(cache_filename, *source_filenames): + """ + Check if a cache file should be invalidated. + + It should be invalidated if any source file has a modification time + more recent than the cache file modification time. + + Parameters + ---------- + cache_filename : Path-like + source_filenames : Collection[Path-like] + + Returns + ------- + bool + """ + try: + stat0 = os.stat(cache_filename) + except FileNotFoundError: + # cache file does not even exist + return True + for i in source_filenames: + stat1 = os.stat(i) + if stat0[ST_MTIME] < stat1[ST_MTIME]: + return True + return False + + +@inject.injectable(cache=True) +def skim_dataset(): + skim_tag = 'taz' + network_los_preload = inject.get_injectable('network_los_preload', None) + if network_los_preload is None: + raise ValueError("missing network_los_preload") + + # find which OMX files are to be used. + omx_file_paths = config.expand_input_file_list( + network_los_preload.omx_file_names(skim_tag), + ) + zarr_file = config.data_file_path( + network_los_preload.zarr_file_name(skim_tag), + mandatory=False, + allow_glob=False, + ) + max_float_precision = network_los_preload.skim_max_float_precision(skim_tag) + + skim_digital_encoding = network_los_preload.skim_digital_encoding(skim_tag) + + # The backing can be plain shared_memory, or a memmap + backing = network_los_preload.skim_backing_store(skim_tag) + if backing == "memmap": + # if memmap is given without a path, create a cache file + mmap_file = os.path.join(config.get_cache_dir(), f"sharrow_dataset_{skim_tag}.mmap") + backing = f"memmap:{mmap_file}" + + with logtime("loading skims as dataset"): + + d = None + if backing.startswith("memmap:"): + # when working with a memmap, check if the memmap file on disk + # needs to be invalidated, because the source skims have been + # modified more recently. + if not should_invalidate_cache_file(backing[7:], *omx_file_paths): + try: + d = sh.Dataset.from_shared_memory(backing, mode="r") + except FileNotFoundError as err: + logger.info(f"skim dataset {skim_tag!r} not found {err!s}") + logger.info(f"loading skim dataset {skim_tag!r} from disk") + d = None + else: + logger.info(f"using skim_dataset from shared memory") + else: + sh.Dataset.delete_shared_memory_files(backing) + else: + # when working in ephemeral shared memory, assume that if that data + # is loaded then it is good to use without further checks. + try: + d = sh.Dataset.from_shared_memory(backing, mode="r") + except FileNotFoundError as err: + logger.info(f"skim dataset {skim_tag!r} not found {err!s}") + logger.info(f"loading skim dataset {skim_tag!r} from disk") + d = None + + if d is None: + time_periods_ = network_los_preload.los_settings['skim_time_periods']['labels'] + # deduplicate time period names + time_periods = [] + for t in time_periods_: + if t not in time_periods: + time_periods.append(t) + if zarr_file: + logger.info(f"looking for zarr skims at {zarr_file}") + if zarr_file and os.path.exists(zarr_file): + # load skims from zarr.zip + logger.info(f"found zarr skims, loading them") + d = sh.Dataset.from_zarr(zarr_file).max_float_precision(max_float_precision) + else: + d = sh.Dataset.from_omx_3d( + [openmatrix.open_file(f) for f in omx_file_paths], + time_periods=time_periods, + max_float_precision=max_float_precision, + ) + if zarr_file: + logger.info(f"writing zarr skims to {zarr_file}") + # save skims to zarr + try: + d.to_zarr(zarr_file) + except ModuleNotFoundError: + logger.warning("the 'zarr' package is not installed") + logger.info(f"scanning for unused skims") + tokens = set(d.variables.keys()) - set(d.coords.keys()) + unused_tokens = scan_for_unused_names(tokens) + if unused_tokens: + logger.info(f"dropping unused skims: {unused_tokens}") + d = d.drop_vars(unused_tokens) + else: + logger.info(f"no unused skims found") + # apply digital encoding + if skim_digital_encoding: + for encoding in skim_digital_encoding: + regex = encoding.pop('regex', None) + if regex: + if 'name' in encoding: + raise ValueError("cannot give both name and regex for digital_encoding") + for k in d.variables: + if re.match(regex, k): + d = d.set_digital_encoding(k, **encoding) + else: + d = d.set_digital_encoding(**encoding) + + # check alignment of TAZs that it matches land_use table + logger.info(f"checking skims alignment with land_use") + land_use = inject.get_table('land_use') + try: + land_use_zone_id = land_use[f'_original_{land_use.index.name}'] + except KeyError: + land_use_zone_id = land_use.index + + if d['otaz'].attrs.get('preprocessed') != 'zero-based-contiguous': + try: + np.testing.assert_array_equal(land_use_zone_id, d.otaz) + except AssertionError as err: + logger.info(f"otaz realignment required\n{err}") + d = d.reindex(otaz=land_use_zone_id) + else: + logger.info(f"otaz alignment ok") + d['otaz'] = land_use.index.to_numpy() + d['otaz'].attrs['preprocessed'] = ('zero-based-contiguous') + else: + np.testing.assert_array_equal(land_use.index, d.otaz) + + if d['dtaz'].attrs.get('preprocessed') != 'zero-based-contiguous': + try: + np.testing.assert_array_equal(land_use_zone_id, d.dtaz) + except AssertionError as err: + logger.info(f"dtaz realignment required\n{err}") + d = d.reindex(dtaz=land_use_zone_id) + else: + logger.info(f"dtaz alignment ok") + d['dtaz'] = land_use.index.to_numpy() + d['dtaz'].attrs['preprocessed'] = ('zero-based-contiguous') + else: + np.testing.assert_array_equal(land_use.index, d.dtaz) + + if d.is_shared_memory: + return d + else: + logger.info(f"writing skims to shared memory") + return d.to_shared_memory(backing, mode="r") + + +def scan_for_unused_names(tokens): + """ + Scan all spec files to find unused skim variable names. + + Parameters + ---------- + tokens : Collection[str] + + Returns + ------- + Set[str] + """ + configs_dir_list = inject.get_injectable('configs_dir') + configs_dir_list = [configs_dir_list] if isinstance(configs_dir_list, str) else configs_dir_list + assert isinstance(configs_dir_list, list) + + for directory in configs_dir_list: + logger.debug(f"scanning for unused skims in {directory}") + filenames = glob.glob(os.path.join(directory, "*.csv")) + for filename in filenames: + with open(filename, 'rt') as f: + content = f.read() + missing_tokens = set() + for t in tokens: + if t not in content: + missing_tokens.add(t) + tokens = missing_tokens + if not tokens: + return tokens + return tokens + + +@inject.injectable(cache=True) +def skim_dataset_dict(skim_dataset): + from .skim_dataset import SkimDataset + return SkimDataset(skim_dataset) + + +def skims_mapping(orig_col_name, dest_col_name, timeframe='tour', stop_col_name=None): + logger.info(f"loading skims_mapping") + logger.info(f"- orig_col_name: {orig_col_name}") + logger.info(f"- dest_col_name: {dest_col_name}") + logger.info(f"- stop_col_name: {stop_col_name}") + skim_dataset = inject.get_injectable('skim_dataset') + if orig_col_name is not None and dest_col_name is not None and stop_col_name is None: + if timeframe == 'timeless': + return dict( + skims=skim_dataset.drop_dims('time_period'), + relationships=( + f"df._orig_col_name -> skims.otaz", + f"df._dest_col_name -> skims.dtaz", + ), + ) + elif timeframe == 'trip': + return dict( + odt_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ptaz', 'dtaz': 'ataz'}), + dot_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ataz', 'dtaz': 'ptaz'}), + od_skims=skim_dataset.drop_dims('time_period').rename_dims_and_coords({'otaz': 'ptaz', 'dtaz': 'ataz'}), + relationships=( + f"df._orig_col_name -> odt_skims.ptaz", + f"df._dest_col_name -> odt_skims.ataz", + f"df.trip_period -> odt_skims.time_period", + f"df._dest_col_name -> dot_skims.ataz", + f"df._orig_col_name -> dot_skims.ptaz", + f"df.trip_period -> dot_skims.time_period", + f"df._orig_col_name -> od_skims.ptaz", + f"df._dest_col_name -> od_skims.ataz", + ), + ) + else: + return dict( + odt_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ptaz', 'dtaz': 'ataz', 'time_period': 'out_period'}), + dot_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ataz', 'dtaz': 'ptaz', 'time_period': 'in_period'}), + odr_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ptaz', 'dtaz': 'ataz', 'time_period': 'in_period'}), + dor_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ataz', 'dtaz': 'ptaz', 'time_period': 'out_period'}), + od_skims=skim_dataset.drop_dims('time_period').rename_dims_and_coords({'otaz': 'ptaz', 'dtaz': 'ataz'}), + relationships=( + f"df._orig_col_name -> odt_skims.ptaz", + f"df._dest_col_name -> odt_skims.ataz", + f"df.out_period @ odt_skims.out_period", + f"df._dest_col_name -> dot_skims.ataz", + f"df._orig_col_name -> dot_skims.ptaz", + f"df.in_period @ dot_skims.in_period", + f"df._orig_col_name -> odr_skims.ptaz", + f"df._dest_col_name -> odr_skims.ataz", + f"df.in_period @ odr_skims.in_period", + f"df._dest_col_name -> dor_skims.ataz", + f"df._orig_col_name -> dor_skims.ptaz", + f"df.out_period @ dor_skims.out_period", + f"df._orig_col_name -> od_skims.ptaz", + f"df._dest_col_name -> od_skims.ataz", + ), + ) + elif stop_col_name is not None: # trip_destination + return dict( + od_skims=skim_dataset.drop_dims('time_period').rename_dims_and_coords({'otaz': 'ptaz', 'dtaz': 'ataz'}), + dp_skims=skim_dataset.drop_dims('time_period').rename_dims_and_coords({'otaz': 'ataz', 'dtaz': 'staz'}), + odt_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ptaz', 'dtaz': 'ataz'}), + dot_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ataz', 'dtaz': 'ptaz'}), + dpt_skims=skim_dataset.rename_dims_and_coords({'otaz': 'ataz', 'dtaz': 'staz'}), + pdt_skims=skim_dataset.rename_dims_and_coords({'otaz': 'staz', 'dtaz': 'ataz'}), + relationships=( + f"df._orig_col_name -> od_skims.ptaz", + f"df._dest_col_name -> od_skims.ataz", + + f"df._dest_col_name -> dp_skims.ataz", + f"df._stop_col_name -> dp_skims.staz", + + f"df._orig_col_name -> odt_skims.ptaz", + f"df._dest_col_name -> odt_skims.ataz", + f"df.trip_period -> odt_skims.time_period", + + f"df._dest_col_name -> dot_skims.ataz", + f"df._orig_col_name -> dot_skims.ptaz", + f"df.trip_period -> dot_skims.time_period", + + f"df._dest_col_name -> dpt_skims.ataz", + f"df._stop_col_name -> dpt_skims.staz", + f"df.trip_period -> dpt_skims.time_period", + + f"df._stop_col_name -> pdt_skims.staz", + f"df._dest_col_name -> pdt_skims.ataz", + f"df.trip_period -> pdt_skims.time_period", + ), + ) + else: + return {} + + + +def new_flow( + spec, + extra_vars, + orig_col_name, + dest_col_name, + trace_label=None, + timeframe='tour', + choosers=None, + stop_col_name=None, + size_term_mapping=None, + interacts=None +): + + with logtime(f"setting up flow {trace_label}"): + if choosers is None: + chooser_cols = [] + else: + chooser_cols = list(choosers.columns) + + cache_dir = os.path.join( + orca.get_injectable('output_dir'), + "cache", + "__sharrowcache__", + ) + os.makedirs(cache_dir, exist_ok=True) + logger.debug(f"flow.cache_dir: {cache_dir}") + skims_mapping_ = skims_mapping(orig_col_name, dest_col_name, timeframe, stop_col_name) + if size_term_mapping is None: + size_term_mapping = {} + + if interacts is None: + flow_tree = sh.DataTree(df=[] if choosers is None else choosers) + idx_name = choosers.index.name or 'index' + rename_dataset_cols = { + idx_name: 'chooserindex', + } + if orig_col_name is not None: + rename_dataset_cols[orig_col_name] = '_orig_col_name' + if dest_col_name is not None: + rename_dataset_cols[dest_col_name] = '_dest_col_name' + if stop_col_name is not None: + rename_dataset_cols[stop_col_name] = '_stop_col_name' + flow_tree.root_dataset = flow_tree.root_dataset.rename(rename_dataset_cols).ensure_integer(['_orig_col_name', '_dest_col_name', '_stop_col_name']) + else: + top = sh.Dataset.from_named_objects( + pd.RangeIndex(len(choosers), name="chooserindex"), + pd.RangeIndex(len(interacts), name="interactindex"), + ) + flow_tree = sh.DataTree(start=top) + rename_dataset_cols = { + orig_col_name: '_orig_col_name', + dest_col_name: '_dest_col_name', + } + if stop_col_name is not None: + rename_dataset_cols[stop_col_name] = '_stop_col_name' + choosers_ = sh.Dataset.construct(choosers).rename_or_ignore(rename_dataset_cols).ensure_integer(['_orig_col_name', '_dest_col_name', '_stop_col_name']) + flow_tree.add_dataset( + 'df', + choosers_, + f"start.chooserindex -> df.{next(iter(choosers_.dims))}" + ) + interacts_ = sh.Dataset.construct(interacts).rename_or_ignore(rename_dataset_cols) + flow_tree.add_dataset( + 'interact_table', + interacts_, + f"start.interactindex -> interact_table.{next(iter(interacts_.dims))}" + ) + + flow_tree.add_items(skims_mapping_) + flow_tree.add_items(size_term_mapping) + flow_tree.extra_vars = extra_vars + + # logger.info(f"initializing sharrow shared data {trace_label}") + # pool = sh.SharedData( + # chooser_cols, + # **skims_mapping_, + # **size_term_mapping, + # extra_vars=extra_vars, + # alias_main="df", + # ) + + # - eval spec expressions + if isinstance(spec.index, pd.MultiIndex): + # spec MultiIndex with expression and label + exprs = spec.index.get_level_values(SPEC_EXPRESSION_NAME) + labels = spec.index.get_level_values(SPEC_LABEL_NAME) + else: + exprs = spec.index + labels = exprs + + defs = {} + for (expr, label) in zip(exprs, labels): + if expr[0] == "@": + if label == expr: + if expr[1:].isidentifier(): + defs[expr[1:]+"_"] = expr[1:] + else: + defs[expr[1:]] = expr[1:] + else: + defs[label] = expr[1:] + elif expr[0] == "_" and "@" in expr: + # - allow temps of form _od_DIST@od_skim['DIST'] + target = expr[:expr.index('@')] + rhs = expr[expr.index('@') + 1:] + defs[target] = rhs + else: + if label == expr and expr.isidentifier(): + defs[expr+"_"] = expr + else: + defs[label] = expr + + readme = f""" + activitysim version: {__version__} + trace label: {trace_label} + orig_col_name: {orig_col_name} + dest_col_name: {dest_col_name} + expressions:""" + for (expr, label) in zip(exprs, labels): + readme += f"\n - {label}: {expr}" + if extra_vars: + readme += f"\n extra_vars:" + for i, v in extra_vars.items(): + readme += f"\n - {i}: {v}" + + logger.info(f"setting up sharrow flow {trace_label}") + return flow_tree.setup_flow( + defs, + cache_dir=cache_dir, + readme=readme[1:], # remove leading newline + flow_library=_FLOWS, + # extra_hash_data=(orig_col_name, dest_col_name), + hashing_level=0, + ) + + +def size_terms_on_flow(locals_d): + if 'size_terms_array' in locals_d: + skim_dataset = inject.get_injectable('skim_dataset') + dest_col_name = locals_d['od_skims'].dest_key + a = sh.Dataset({'arry': sh.DataArray( + locals_d['size_terms_array'], + dims=['stoptaz', 'purpose_index'], + coords={ + 'stoptaz': np.asarray(inject.get_table("land_use").to_frame().index), + } + )}) + a = a.reindex(stoptaz=skim_dataset.coords['dtaz'].values) + locals_d['size_array'] = dict( + size_terms=a.set_match_names( + {'stoptaz': f'@{dest_col_name}^', 'purpose_index': f'purpose_index_num^'}, + ), + relationships=( + f"df._dest_col_name -> size_terms.stoptaz", + f"df.purpose_index_num -> size_terms.purpose_index", + ), + ) + return locals_d + +def apply_flow(spec, choosers, locals_d=None, trace_label=None, required=False, interacts=None): + if sh is None: + return None, None, None + if locals_d is None: + locals_d = {} + with logtime("apply_flow"): + try: + flow = get_flow(spec, locals_d, trace_label, choosers=choosers, interacts=interacts) + except ValueError as err: + if "unable to rewrite" in str(err): + logger.error(f"error in apply_flow: {err!s}") + if required: + raise + return None, None, None + else: + raise + with logtime("flow.load", trace_label or ''): + try: + flow_result, flow_indexes = flow.load( + dot=spec.values.astype(np.float32), + dtype=np.float32, + return_indexes=True, + ) + except ValueError as err: + if "could not convert" in str(err): + logger.error(f"error in apply_flow: {err!s}") + if required: + raise + return None, flow, None + raise + except Exception as err: + logger.error(f"error in apply_flow: {err!s}") + # index_keys = self.shared_data.meta_match_names_idx.keys() + # logger.debug(f"Flow._get_indexes: {index_keys}") + raise + return flow_result, flow, flow_indexes \ No newline at end of file diff --git a/activitysim/core/input.py b/activitysim/core/input.py index 3ae3fd806..acbbe443c 100644 --- a/activitysim/core/input.py +++ b/activitysim/core/input.py @@ -88,6 +88,7 @@ def read_from_table_info(table_info): column_map = table_info.get('column_map', None) keep_columns = table_info.get('keep_columns', None) rename_columns = table_info.get('rename_columns', None) + recode_columns = table_info.get('recode_columns', None) csv_dtypes = table_info.get('dtypes', {}) # don't require a redundant index_col directive for canonical tables @@ -152,6 +153,26 @@ def read_from_table_info(table_info): logger.debug("renaming columns: %s" % rename_columns) df.rename(columns=rename_columns, inplace=True) + # recode columns, can simplify data structure + if recode_columns: + for colname, recode_instruction in recode_columns.items(): + logger.info(f"recoding column {colname}: {recode_instruction}") + if recode_instruction == "zero-based": + remapper = {j:i for i,j in enumerate(sorted(set(df[colname])))} + df[f"_original_{colname}"] = df[colname] + df[colname] = df[colname].apply(remapper.get) + if keep_columns: + keep_columns.append(f"_original_{colname}") + else: + source_table, lookup_col = recode_instruction.split(".") + parent_table = inject.get_table(source_table) + try: + map_col = parent_table[f"_original_{lookup_col}"] + except KeyError: + map_col = parent_table[lookup_col] + remapper = dict(zip(map_col, parent_table.index)) + df[colname] = df[colname].apply(remapper.get) + # set index if index_col is not None: if index_col in df.columns: @@ -193,7 +214,7 @@ def read_from_table_info(table_info): def _read_input_file(filepath, h5_tablename=None, csv_dtypes=None): assert os.path.exists(filepath), 'input file not found: %s' % filepath - if filepath.endswith('.csv'): + if filepath.endswith('.csv') or filepath.endswith('.csv.gz'): return _read_csv_with_fallback_encoding(filepath, csv_dtypes) if filepath.endswith('.h5'): diff --git a/activitysim/core/interaction_sample.py b/activitysim/core/interaction_sample.py index d792401dc..d55f3639f 100644 --- a/activitysim/core/interaction_sample.py +++ b/activitysim/core/interaction_sample.py @@ -16,6 +16,7 @@ from . import interaction_simulate from . import pipeline +from . import config logger = logging.getLogger(__name__) @@ -63,13 +64,13 @@ def make_sample_choices( probs = probs[~zero_probs] choosers = choosers[~zero_probs] - cum_probs_array = probs.values.cumsum(axis=1) - chunk.log_df(trace_label, 'cum_probs_array', cum_probs_array) - - # alt probs in convenient layout to return prob of chose alternative - # (same layout as cum_probs_arr) - alt_probs_array = probs.values.flatten() - chunk.log_df(trace_label, 'alt_probs_array', alt_probs_array) + # cum_probs_array = probs.values.cumsum(axis=1) + # chunk.log_df(trace_label, 'cum_probs_array', cum_probs_array) + # + # # alt probs in convenient layout to return prob of chose alternative + # # (same layout as cum_probs_arr) + # alt_probs_array = probs.values.flatten() + # chunk.log_df(trace_label, 'alt_probs_array', alt_probs_array) # get sample_size rands for each chooser rands = pipeline.get_rn_generator().random_for_df(probs, n=sample_size) @@ -77,61 +78,73 @@ def make_sample_choices( # transform as we iterate over alternatives # reshape so rands[i] is in broadcastable (2-D) shape for cum_probs_arr # i.e rands[i] is a 2-D array of one alt choice rand for each chooser - rands = rands.T.reshape(sample_size, -1, 1) + # rands = rands.T #.reshape(sample_size, -1, 1) chunk.log_df(trace_label, 'rands', rands) - # the alternative value chosen - # WHY SHOULD CHOICES COL HAVE TO BE TYPE INT??? + # + # # the alternative value chosen # choices_array = np.empty([sample_size, len(choosers)]).astype(int) - choices_array = np.empty([sample_size, len(choosers)]).astype(alternatives.index.dtype) - # chunk log these later after we populate them... - - # the probability of the chosen alternative - choice_probs_array = np.empty([sample_size, len(choosers)]) - # chunk log these later after we populate them... - - alts = np.tile(alternatives.index.values, len(choosers)) - chunk.log_df(trace_label, 'alts', alts) - - # FIXME - do this all at once rather than iterate? - for i in range(sample_size): - - # FIXME - do this in numpy, not pandas? - - # rands for this alt in broadcastable shape - r = rands[i] - - # position of first occurrence of positive value - positions = np.argmax(cum_probs_array > r, axis=1) - - # FIXME - leave positions as numpy array, not pandas series? - # positions is series with the chosen alternative represented as a column index in probs - # which is an integer between zero and num alternatives in the alternative sample - positions = pd.Series(positions, index=probs.index) - - # need to get from an integer offset into the alternative sample to the alternative index - # that is, we want the index value of the row that is offset by rows into the - # tranche of this choosers alternatives created by cross join of alternatives and choosers - - # offsets is the offset into model_design df of first row of chooser alternatives - offsets = np.arange(len(positions)) * alternative_count - - # choices and choice_probs have one element per chooser and is in same order as choosers - choices_array[i] = np.take(alts, positions + offsets) - choice_probs_array[i] = np.take(alt_probs_array, positions + offsets) - - del positions - del offsets + # # chunk log these later after we populate them... + # + # # the probability of the chosen alternative + # choice_probs_array = np.empty([sample_size, len(choosers)]) + # # chunk log these later after we populate them... + # + # alts = np.tile(alternatives.index.values, len(choosers)) + # chunk.log_df(trace_label, 'alts', alts) + # + # # FIXME - do this all at once rather than iterate? + # for i in range(sample_size): + # + # # FIXME - do this in numpy, not pandas? + # + # # rands for this alt in broadcastable shape + # r = rands[i] + # + # # position of first occurrence of positive value + # positions = np.argmax(cum_probs_array > r, axis=1) + # + # # FIXME - leave positions as numpy array, not pandas series? + # # positions is series with the chosen alternative represented as a column index in probs + # # which is an integer between zero and num alternatives in the alternative sample + # positions = pd.Series(positions, index=probs.index) + # + # # need to get from an integer offset into the alternative sample to the alternative index + # # that is, we want the index value of the row that is offset by rows into the + # # tranche of this choosers alternatives created by cross join of alternatives and choosers + # + # # offsets is the offset into model_design df of first row of chooser alternatives + # offsets = np.arange(len(positions)) * alternative_count + # + # # choices and choice_probs have one element per chooser and is in same order as choosers + # choices_array[i] = np.take(alts, positions + offsets) + # choice_probs_array[i] = np.take(alt_probs_array, positions + offsets) + # + # del positions + # del offsets + # + # chunk.log_df(trace_label, 'choices_array', choices_array) + # chunk.log_df(trace_label, 'choice_probs_array', choice_probs_array) + # + # del alts + # chunk.log_df(trace_label, 'alts', None) + # del cum_probs_array + # chunk.log_df(trace_label, 'cum_probs_array', None) + # del alt_probs_array + # chunk.log_df(trace_label, 'alt_probs_array', None) + + from .choosing import sample_choices_maker + choices_array, choice_probs_array = sample_choices_maker( + probs.values, + rands, + alternatives.index.values, + ) chunk.log_df(trace_label, 'choices_array', choices_array) chunk.log_df(trace_label, 'choice_probs_array', choice_probs_array) - del alts - chunk.log_df(trace_label, 'alts', None) - del cum_probs_array - chunk.log_df(trace_label, 'cum_probs_array', None) - del alt_probs_array - chunk.log_df(trace_label, 'alt_probs_array', None) + # np.testing.assert_array_equal(choices_array, choices_array__) + # np.testing.assert_array_almost_equal(choice_probs_array, choice_probs_array__) # explode to one row per chooser.index, alt_zone_id choices_df = pd.DataFrame( @@ -240,47 +253,88 @@ def _interaction_sample( chooser_index_id = interaction_simulate.ALT_CHOOSER_ID if log_alt_losers else None + sharrow_enabled = config.setting("sharrow", False) + # - cross join choosers and alternatives (cartesian product) # for every chooser, there will be a row for each alternative # index values (non-unique) are from alternatives df alternative_count = alternatives.shape[0] - interaction_df = \ - logit.interaction_dataset(choosers, alternatives, sample_size=alternative_count, - chooser_index_id=chooser_index_id) - - chunk.log_df(trace_label, 'interaction_df', interaction_df) - - assert alternative_count == len(interaction_df.index) / len(choosers.index) - - if skims is not None: - set_skim_wrapper_targets(interaction_df, skims) - - # evaluate expressions from the spec multiply by coefficients and sum - # spec is df with one row per spec expression and one col with utility coefficient - # column names of interaction_df match spec index values - # utilities has utility value for element in the cross product of choosers and alternatives - # interaction_utilities is a df with one utility column and one row per row in interaction_df - if have_trace_targets: - trace_rows, trace_ids \ - = tracing.interaction_trace_rows(interaction_df, choosers, alternative_count) - - tracing.trace_df(interaction_df[trace_rows], - tracing.extend_trace_label(trace_label, 'interaction_df'), - slicer='NONE', transpose=False) - else: - trace_rows = trace_ids = None - - # interaction_utilities is a df with one utility column and one row per interaction_df row - interaction_utilities, trace_eval_results \ - = interaction_simulate.eval_interaction_utilities(spec, interaction_df, locals_d, trace_label, trace_rows, - estimator=None, - log_alt_losers=log_alt_losers) - chunk.log_df(trace_label, 'interaction_utilities', interaction_utilities) - - # ########### HWM - high water mark (point of max observed memory usage) - del interaction_df - chunk.log_df(trace_label, 'interaction_df', None) + interaction_utilities = None + interaction_utilities_sh = None + if sharrow_enabled: + interaction_utilities, trace_eval_results = interaction_simulate.eval_interaction_utilities( + spec, + choosers, + locals_d, + trace_label, + None, + estimator=None, + log_alt_losers=log_alt_losers, + extra_data=alternatives, + ) + chunk.log_df(trace_label, 'interaction_utilities', interaction_utilities) + if sharrow_enabled == 'test': + interaction_utilities_sh, trace_eval_results_sh = interaction_utilities, trace_eval_results + if not sharrow_enabled or (sharrow_enabled == 'test'): + interaction_df = logit.interaction_dataset( + choosers, + alternatives, + sample_size=alternative_count, + chooser_index_id=chooser_index_id, + ) + + chunk.log_df(trace_label, 'interaction_df', interaction_df) + + assert alternative_count == len(interaction_df.index) / len(choosers.index) + + if skims is not None: + set_skim_wrapper_targets(interaction_df, skims) + + # evaluate expressions from the spec multiply by coefficients and sum + # spec is df with one row per spec expression and one col with utility coefficient + # column names of interaction_df match spec index values + # utilities has utility value for element in the cross product of choosers and alternatives + # interaction_utilities is a df with one utility column and one row per row in interaction_df + if have_trace_targets: + trace_rows, trace_ids \ + = tracing.interaction_trace_rows(interaction_df, choosers, alternative_count) + + tracing.trace_df(interaction_df[trace_rows], + tracing.extend_trace_label(trace_label, 'interaction_df'), + slicer='NONE', transpose=False) + else: + trace_rows = trace_ids = None + + # interaction_utilities is a df with one utility column and one row per interaction_df row + interaction_utilities, trace_eval_results \ + = interaction_simulate.eval_interaction_utilities(spec, interaction_df, locals_d, trace_label, trace_rows, + estimator=None, + log_alt_losers=log_alt_losers) + chunk.log_df(trace_label, 'interaction_utilities', interaction_utilities) + + # ########### HWM - high water mark (point of max observed memory usage) + + del interaction_df + chunk.log_df(trace_label, 'interaction_df', None) + + if sharrow_enabled == 'test': + try: + if interaction_utilities_sh is not None: + np.testing.assert_allclose( + interaction_utilities_sh.values.reshape(interaction_utilities.values.shape), interaction_utilities.values, rtol=1e-2, atol=0, + err_msg='utility not aligned', verbose=True, + ) + except AssertionError as err: + print(err) + misses = np.where(~np.isclose(interaction_utilities_sh.values, interaction_utilities.values, rtol=1e-2, atol=0)) + _sh_util_miss1 = interaction_utilities_sh.values[tuple(m[0] for m in misses)] + _u_miss1 = interaction_utilities.values[tuple(m[0] for m in misses)] + diff = _sh_util_miss1 - _u_miss1 + if len(misses[0]) > interaction_utilities_sh.values.size * 0.01: + print("big problem") + print(misses) + raise if have_trace_targets: tracing.trace_interaction_eval_results(trace_eval_results, trace_ids, diff --git a/activitysim/core/interaction_simulate.py b/activitysim/core/interaction_simulate.py index 292c9ccae..f040e1359 100644 --- a/activitysim/core/interaction_simulate.py +++ b/activitysim/core/interaction_simulate.py @@ -3,6 +3,10 @@ from builtins import zip import logging +import time +from datetime import timedelta + +_OLD_TIME, _NEW_TIME = 0, 0 import numpy as np import pandas as pd @@ -23,7 +27,7 @@ ALT_CHOOSER_ID = '_chooser_id' -def eval_interaction_utilities(spec, df, locals_d, trace_label, trace_rows, estimator=None, log_alt_losers=False): +def eval_interaction_utilities(spec, df, locals_d, trace_label, trace_rows, estimator=None, log_alt_losers=False, extra_data=None): """ Compute the utilities for a single-alternative spec evaluated in the context of df @@ -62,9 +66,23 @@ def eval_interaction_utilities(spec, df, locals_d, trace_label, trace_rows, esti Will have the index of `df` and a single column of utilities """ + start_time = time.time() + trace_label = tracing.extend_trace_label(trace_label, "eval_interaction_utils") logger.info("Running eval_interaction_utilities on %s rows" % df.shape[0]) + # from .flow import apply_flow + # from . import inject + # skim_dataset = inject.get_injectable('skim_dataset') + sharrow_enabled = config.setting("sharrow", False) + + # if trace_label.startswith("trip_destination"): + # sharrow_enabled = False + + logger.info(f"{trace_label} sharrow_enabled is {sharrow_enabled}") + + trace_eval_results = None + with chunk.chunk_log(trace_label): assert(len(spec.columns) == 1) @@ -72,165 +90,260 @@ def eval_interaction_utilities(spec, df, locals_d, trace_label, trace_rows, esti # avoid altering caller's passed-in locals_d parameter (they may be looping) locals_d = locals_d.copy() if locals_d is not None else {} + utilities = None + + from .flow import TimeLogger + timelogger = TimeLogger("interaction_simulate") + + t0 = time.time() + # add df for startswith('@') eval expressions locals_d['df'] = df - def to_series(x): - if np.isscalar(x): - return pd.Series([x] * len(df), index=df.index) - if isinstance(x, np.ndarray): - return pd.Series(x, index=df.index) - return x - - if trace_rows is not None and trace_rows.any(): - # # convert to numpy array so we can slice ndarrays as well as series - # trace_rows = np.asanyarray(trace_rows) - assert type(trace_rows) == np.ndarray - trace_eval_results = OrderedDict() + if sharrow_enabled and not {'tt', } & set(locals_d.keys()): # timetables not yet compatible + + from .flow import apply_flow + + # need to zero out any coefficients on temp vars + if isinstance(spec.index, pd.MultiIndex): + exprs = spec.index.get_level_values(simulate.SPEC_EXPRESSION_NAME) + labels = spec.index.get_level_values(simulate.SPEC_LABEL_NAME) + else: + exprs = spec.index + labels = spec.index + for n, (expr, label) in enumerate(zip(exprs, labels)): + if expr.startswith('_') and '@' in expr: + spec.iloc[n, 0] = 0.0 + + for i1, i2 in zip(exprs, labels): + logger.info(f" - expr: {i1}: {i2}") + + timelogger.mark("sharrow preamble", True, logger, trace_label) + + sh_util, sh_flow, sh_flow_idxs = apply_flow(spec, df, locals_d, trace_label, interacts=extra_data) + if sh_util is not None: + chunk.log_df(trace_label, 'sh_util', sh_util) + chunk.log_df(trace_label, 'sh_flow_idxs', sh_flow_idxs) + utilities = pd.DataFrame( + {'utility': sh_util.reshape(-1)}, + index=df.index if extra_data is None else None, + ) + chunk.log_df(trace_label, 'sh_util', None) # hand off to caller + del sh_flow_idxs + chunk.log_df(trace_label, 'sh_flow_idxs', None) + + timelogger.mark("sharrow flow", True, logger, trace_label) else: - trace_eval_results = None + sh_util, sh_flow = None, None + timelogger.mark("sharrow flow", False) - check_for_variability = config.setting('check_for_variability') + t1 = time.time() + + if utilities is None or estimator or (sharrow_enabled == 'test' and extra_data is None): - # need to be able to identify which variables causes an error, which keeps - # this from being expressed more parsimoniously + def to_series(x): + if np.isscalar(x): + return pd.Series([x] * len(df), index=df.index) + if isinstance(x, np.ndarray): + return pd.Series(x, index=df.index) + return x - utilities = pd.DataFrame({'utility': 0.0}, index=df.index) + if trace_rows is not None and trace_rows.any(): + # # convert to numpy array so we can slice ndarrays as well as series + # trace_rows = np.asanyarray(trace_rows) + assert type(trace_rows) == np.ndarray + trace_eval_results = OrderedDict() + else: + trace_eval_results = None - chunk.log_df(trace_label, 'eval.utilities', utilities) + check_for_variability = config.setting('check_for_variability') - no_variability = has_missing_vals = 0 + # need to be able to identify which variables causes an error, which keeps + # this from being expressed more parsimoniously - if estimator: - # ensure alt_id from interaction_dataset is available in expression_values_df for - # estimator.write_interaction_expression_values and eventual omnibus table assembly - alt_id = estimator.get_alt_id() - assert alt_id in df.columns - expression_values_df = df[[alt_id]] + utilities = pd.DataFrame({'utility': 0.0}, index=df.index) - # FIXME estimation_requires_chooser_id_in_df_column - # estimation requires that chooser_id is either in index or a column of interaction_dataset - # so it can be reformatted (melted) and indexed by chooser_id and alt_id - # we assume caller has this under control if index is named - # bug - location choice has df index_name zone_id but should be person_id???? - if df.index.name is None: - chooser_id = estimator.get_chooser_id() - assert chooser_id in df.columns, \ - "Expected to find choose_id column '%s' in interaction dataset" % (chooser_id, ) - assert df.index.name is None - expression_values_df[chooser_id] = df[chooser_id] + chunk.log_df(trace_label, 'eval.utilities', utilities) - if isinstance(spec.index, pd.MultiIndex): - exprs = spec.index.get_level_values(simulate.SPEC_EXPRESSION_NAME) - labels = spec.index.get_level_values(simulate.SPEC_LABEL_NAME) - else: - exprs = spec.index - labels = spec.index + no_variability = has_missing_vals = 0 - for expr, label, coefficient in zip(exprs, labels, spec.iloc[:, 0]): - try: + if estimator: + # ensure alt_id from interaction_dataset is available in expression_values_df for + # estimator.write_interaction_expression_values and eventual omnibus table assembly + alt_id = estimator.get_alt_id() + assert alt_id in df.columns + expression_values_df = df[[alt_id]] - # - allow temps of form _od_DIST@od_skim['DIST'] - if expr.startswith('_'): + # FIXME estimation_requires_chooser_id_in_df_column + # estimation requires that chooser_id is either in index or a column of interaction_dataset + # so it can be reformatted (melted) and indexed by chooser_id and alt_id + # we assume caller has this under control if index is named + # bug - location choice has df index_name zone_id but should be person_id???? + if df.index.name is None: + chooser_id = estimator.get_chooser_id() + assert chooser_id in df.columns, \ + "Expected to find choose_id column '%s' in interaction dataset" % (chooser_id, ) + assert df.index.name is None + expression_values_df[chooser_id] = df[chooser_id] - target = expr[:expr.index('@')] - rhs = expr[expr.index('@') + 1:] - v = to_series(eval(rhs, globals(), locals_d)) + if isinstance(spec.index, pd.MultiIndex): + exprs = spec.index.get_level_values(simulate.SPEC_EXPRESSION_NAME) + labels = spec.index.get_level_values(simulate.SPEC_LABEL_NAME) + else: + exprs = spec.index + labels = spec.index - # update locals to allows us to ref previously assigned targets - locals_d[target] = v - chunk.log_df(trace_label, target, v) # track temps stored in locals + for expr, label, coefficient in zip(exprs, labels, spec.iloc[:, 0]): + try: - if trace_eval_results is not None: - trace_eval_results[expr] = v[trace_rows] + # - allow temps of form _od_DIST@od_skim['DIST'] + if expr.startswith('_'): + + target = expr[:expr.index('@')] + rhs = expr[expr.index('@') + 1:] + v = to_series(eval(rhs, globals(), locals_d)) - # don't add temps to utility sums - # they have a non-zero dummy coefficient to avoid being removed from spec as NOPs - continue + # update locals to allows us to ref previously assigned targets + locals_d[target] = v + chunk.log_df(trace_label, target, v) # track temps stored in locals - if expr.startswith('@'): - v = to_series(eval(expr[1:], globals(), locals_d)) - else: - v = df.eval(expr) + if trace_eval_results is not None: + trace_eval_results[expr] = v[trace_rows] - if check_for_variability and v.std() == 0: - logger.info("%s: no variability (%s) in: %s" % (trace_label, v.iloc[0], expr)) - no_variability += 1 + # don't add temps to utility sums + # they have a non-zero dummy coefficient to avoid being removed from spec as NOPs + continue - # FIXME - how likely is this to happen? Not sure it is really a problem? - if check_for_variability and np.count_nonzero(v.isnull().values) > 0: - logger.info("%s: missing values in: %s" % (trace_label, expr)) - has_missing_vals += 1 + if expr.startswith('@'): + v = to_series(eval(expr[1:], globals(), locals_d)) + else: + v = df.eval(expr) - if estimator: - # in case we modified expression_values_df index - expression_values_df.insert(loc=len(expression_values_df.columns), column=label, - value=v.values if isinstance(v, pd.Series) else v) + if check_for_variability and v.std() == 0: + logger.info("%s: no variability (%s) in: %s" % (trace_label, v.iloc[0], expr)) + no_variability += 1 - utility = (v * coefficient).astype('float') + # FIXME - how likely is this to happen? Not sure it is really a problem? + if check_for_variability and np.count_nonzero(v.isnull().values) > 0: + logger.info("%s: missing values in: %s" % (trace_label, expr)) + has_missing_vals += 1 - if log_alt_losers: + if estimator: + # in case we modified expression_values_df index + expression_values_df.insert(loc=len(expression_values_df.columns), column=label, + value=v.values if isinstance(v, pd.Series) else v) - assert ALT_CHOOSER_ID in df - max_utils_by_chooser = utility.groupby(df[ALT_CHOOSER_ID]).max() + utility = (v * coefficient).astype('float') - if (max_utils_by_chooser < simulate.ALT_LOSER_UTIL).any(): + if log_alt_losers: - losers = max_utils_by_chooser[max_utils_by_chooser < simulate.ALT_LOSER_UTIL] - logger.warning(f"{trace_label} - {len(losers)} choosers of {len(max_utils_by_chooser)} " - f"with prohibitive utilities for all alternatives for expression: {expr}") + assert ALT_CHOOSER_ID in df + max_utils_by_chooser = utility.groupby(df[ALT_CHOOSER_ID]).max() - # loser_df = df[df[ALT_CHOOSER_ID].isin(losers.index)] - # print(f"\nloser_df\n{loser_df}\n") - # print(f"\nloser_max_utils_by_chooser\n{losers}\n") - # bug + if (max_utils_by_chooser < simulate.ALT_LOSER_UTIL).any(): - del max_utils_by_chooser + losers = max_utils_by_chooser[max_utils_by_chooser < simulate.ALT_LOSER_UTIL] + logger.warning(f"{trace_label} - {len(losers)} choosers of {len(max_utils_by_chooser)} " + f"with prohibitive utilities for all alternatives for expression: {expr}") - utilities.utility += utility + # loser_df = df[df[ALT_CHOOSER_ID].isin(losers.index)] + # print(f"\nloser_df\n{loser_df}\n") + # print(f"\nloser_max_utils_by_chooser\n{losers}\n") + # bug - if trace_eval_results is not None: + del max_utils_by_chooser - # expressions should have been uniquified when spec was read - # (though we could do it here if need be...) - # expr = assign.uniquify_key(trace_eval_results, expr, template="{} # ({})") - assert expr not in trace_eval_results + utilities.utility += utility - trace_eval_results[expr] = v[trace_rows] - k = 'partial utility (coefficient = %s) for %s' % (coefficient, expr) - trace_eval_results[k] = v[trace_rows] * coefficient + if trace_eval_results is not None: + + # expressions should have been uniquified when spec was read + # (though we could do it here if need be...) + # expr = assign.uniquify_key(trace_eval_results, expr, template="{} # ({})") + assert expr not in trace_eval_results - del v - # chunk.log_df(trace_label, 'v', None) + trace_eval_results[expr] = v[trace_rows] + k = 'partial utility (coefficient = %s) for %s' % (coefficient, expr) + trace_eval_results[k] = v[trace_rows] * coefficient - except Exception as err: - logger.exception(f"{trace_label} - {type(err).__name__} ({str(err)}) evaluating: {str(expr)}") - raise err + del v + # chunk.log_df(trace_label, 'v', None) - if estimator: - estimator.log("eval_interaction_utilities write_interaction_expression_values %s" % trace_label) - estimator.write_interaction_expression_values(expression_values_df) - del expression_values_df + except Exception as err: + logger.exception(f"{trace_label} - {type(err).__name__} ({str(err)}) evaluating: {str(expr)}") + raise err - if no_variability > 0: - logger.warning("%s: %s columns have no variability" % (trace_label, no_variability)) + if estimator: + estimator.log("eval_interaction_utilities write_interaction_expression_values %s" % trace_label) + estimator.write_interaction_expression_values(expression_values_df) + del expression_values_df - if has_missing_vals > 0: - logger.warning("%s: %s columns have missing values" % (trace_label, has_missing_vals)) + if no_variability > 0: + logger.warning("%s: %s columns have no variability" % (trace_label, no_variability)) - if trace_eval_results is not None: - trace_eval_results['total utility'] = utilities.utility[trace_rows] + if has_missing_vals > 0: + logger.warning("%s: %s columns have missing values" % (trace_label, has_missing_vals)) - trace_eval_results = pd.DataFrame.from_dict(trace_eval_results) - trace_eval_results.index = df[trace_rows].index + if trace_eval_results is not None: + trace_eval_results['total utility'] = utilities.utility[trace_rows] - # add df columns to trace_results - trace_eval_results = pd.concat([df[trace_rows], trace_eval_results], axis=1) - chunk.log_df(trace_label, 'eval.trace_eval_results', trace_eval_results) + trace_eval_results = pd.DataFrame.from_dict(trace_eval_results) + trace_eval_results.index = df[trace_rows].index - chunk.log_df(trace_label, 'v', None) - chunk.log_df(trace_label, 'eval.utilities', None) # out of out hands... - chunk.log_df(trace_label, 'eval.trace_eval_results', None) + # add df columns to trace_results + trace_eval_results = pd.concat([df[trace_rows], trace_eval_results], axis=1) + chunk.log_df(trace_label, 'eval.trace_eval_results', trace_eval_results) + + chunk.log_df(trace_label, 'v', None) + chunk.log_df(trace_label, 'eval.utilities', None) # out of out hands... + chunk.log_df(trace_label, 'eval.trace_eval_results', None) + + timelogger.mark("regular interact flow", True, logger, trace_label) + else: + timelogger.mark("regular interact flow", False) + + if sh_flow is not None and trace_eval_results is not None: + sh_utility_fat = sh_flow.load( + sh_flow.shared_data.replace_datasets( + df=df.loc[trace_rows], + ), + dtype=np.float32, + ) + sh_utility_fat1 = np.dot(sh_utility_fat, spec.values) + sh_utility_fat2 = sh_flow.load( + sh_flow.shared_data.replace_datasets( + df=df.loc[trace_rows], + ), + dot=spec.values.astype(np.float32), + dtype=np.float32, + ) + timelogger.mark("sharrow interact trace", True, logger, trace_label) + + if sharrow_enabled == 'test': + + try: + if sh_util is not None: + np.testing.assert_allclose( + sh_util.reshape(utilities.values.shape), utilities.values, rtol=1e-2, atol=0, + err_msg='utility not aligned', verbose=True, + ) + except AssertionError as err: + print(err) + misses = np.where(~np.isclose(sh_util, utilities.values, rtol=1e-2, atol=0)) + _sh_util_miss1 = sh_util[tuple(m[0] for m in misses)] + _u_miss1 = utilities.values[tuple(m[0] for m in misses)] + diff = _sh_util_miss1 - _u_miss1 + if len(misses[0]) > sh_util.size * 0.01: + print("big problem") + print(misses) + raise + timelogger.mark("sharrow interact test", True, logger, trace_label) + + logger.info(f"utilities.dtypes {trace_label}\n{utilities.dtypes}") + end_time = time.time() + + timelogger.summary(logger, "TIMING interact_simulate.eval_utils") + logger.info(f"interact_simulate.eval_utils runtime: {timedelta(seconds=end_time - start_time)} {trace_label}") return utilities, trace_eval_results diff --git a/activitysim/core/logit.py b/activitysim/core/logit.py index b3dace2e0..243390781 100644 --- a/activitysim/core/logit.py +++ b/activitysim/core/logit.py @@ -10,6 +10,7 @@ from . import tracing from . import pipeline from . import config +from .choosing import choice_maker logger = logging.getLogger(__name__) @@ -145,22 +146,26 @@ def utils_to_probs(utils, trace_label=None, exponentiated=False, allow_zero_prob # utils_arr = utils.values.astype('float') utils_arr = utils.values if not exponentiated: + # TODO: reduce memory usage by exponentiating in-place. + # but first we need to make sure the raw utilities + # are not needed elsewhere and overwriting won't hurt. + # try: + # np.exp(utils_arr, out=utils_arr) + # except TypeError: + # utils_arr = np.exp(utils_arr) utils_arr = np.exp(utils_arr) - np.clip(utils_arr, EXP_UTIL_MIN, EXP_UTIL_MAX, out=utils_arr) - - # FIXME - utils_arr = np.where(utils_arr == EXP_UTIL_MIN, 0.0, utils_arr) + np.putmask(utils_arr, utils_arr <= EXP_UTIL_MIN, 0) arr_sum = utils_arr.sum(axis=1) - zero_probs = (arr_sum == 0.0) - if zero_probs.any() and not allow_zero_probs: - - report_bad_choices(zero_probs, utils, - trace_label=tracing.extend_trace_label(trace_label, 'zero_prob_utils'), - msg="all probabilities are zero", - trace_choosers=trace_choosers) + if not allow_zero_probs: + zero_probs = (arr_sum == 0.0) + if zero_probs.any(): + report_bad_choices(zero_probs, utils, + trace_label=tracing.extend_trace_label(trace_label, 'zero_prob_utils'), + msg="all probabilities are zero", + trace_choosers=trace_choosers) inf_utils = np.isinf(arr_sum) if inf_utils.any(): @@ -175,7 +180,7 @@ def utils_to_probs(utils, trace_label=None, exponentiated=False, allow_zero_prob np.divide(utils_arr, arr_sum.reshape(len(utils_arr), 1), out=utils_arr) # if allow_zero_probs, this will cause EXP_UTIL_MIN util rows to have all zero probabilities - utils_arr[np.isnan(utils_arr)] = PROB_MIN + np.putmask(utils_arr, np.isnan(utils_arr), PROB_MIN) np.clip(utils_arr, PROB_MIN, PROB_MAX, out=utils_arr) @@ -228,13 +233,7 @@ def make_choices(probs, trace_label=None, trace_choosers=None, allow_bad_probs=F rands = pipeline.get_rn_generator().random_for_df(probs) - probs_arr = probs.values.cumsum(axis=1) - rands - - # rows, cols = np.where(probs_arr > 0) - # choices = [s.iat[0] for _, s in pd.Series(cols).groupby(rows)] - choices = np.argmax(probs_arr > 0.0, axis=1) - - choices = pd.Series(choices, index=probs.index) + choices = pd.Series(choice_maker(probs.values, rands), index=probs.index) rands = pd.Series(np.asanyarray(rands).flatten(), index=probs.index) diff --git a/activitysim/core/los.py b/activitysim/core/los.py index a76dbe97d..6cc630560 100644 --- a/activitysim/core/los.py +++ b/activitysim/core/los.py @@ -9,6 +9,7 @@ import pandas as pd from activitysim.core import skim_dictionary +from activitysim.core import skim_dataset from activitysim.core import inject from activitysim.core import util from activitysim.core import config @@ -126,11 +127,16 @@ def setting(self, keys, default=''): s = self.los_settings for key in key_list[:-1]: s = s.get(key) - assert isinstance(s, dict), f"expected key '{key}' not found in '{keys}' in {self.los_settings_file_name}" + if default == '': + assert isinstance(s, dict), f"expected key '{key}' not found in '{keys}' in {self.los_settings_file_name}" key = key_list[-1] # last key if default == '': assert key in s, f"Expected setting {keys} not found in in {LOS_SETTINGS_FILE_NAME}" - return s.get(key, default) + if isinstance(s, dict): + return s.get(key, default) + else: + return default + def load_settings(self): """ @@ -327,11 +333,17 @@ def load_data(self): self.maz_to_tap_dfs[mode] = df # create taz skim dict - assert 'taz' not in self.skim_dicts - self.skim_dicts['taz'] = self.create_skim_dict('taz') - - # make sure skim has all taz_ids - # FIXME - weird that there is no list of tazs? + if not config.setting("sharrow", False): + assert 'taz' not in self.skim_dicts + # If offset_preprocessing was completed, then TAZ values + # will be pre-offset and there's no need to re-offset them. + if config.setting("offset_preprocessing", False): + _override_offset_int = 0 + else: + _override_offset_int = None + self.skim_dicts['taz'] = self.create_skim_dict('taz', _override_offset_int=_override_offset_int) + # make sure skim has all taz_ids + # FIXME - weird that there is no list of tazs? # create MazSkimDict facade if self.zone_system in [TWO_ZONE, THREE_ZONE]: @@ -352,7 +364,7 @@ def load_data(self): # make sure skim has all tap_ids assert not (tap_skim_dict.offset_mapper.map(self.tap_df['TAP'].values) == NOT_IN_SKIM_ZONE_ID).any() - def create_skim_dict(self, skim_tag): + def create_skim_dict(self, skim_tag, _override_offset_int=None): """ Create a new SkimDict of type specified by skim_tag (e.g. 'taz', 'maz' or 'tap') @@ -381,6 +393,9 @@ def create_skim_dict(self, skim_tag): logger.debug(f"create_skim_dict {skim_tag} omx_shape {skim_dict.omx_shape}") + if _override_offset_int is not None: + skim_dict.offset_mapper.set_offset_int(_override_offset_int) # default is -1 + return skim_dict def omx_file_names(self, skim_tag): @@ -396,9 +411,43 @@ def omx_file_names(self, skim_tag): list of str """ file_names = self.setting(f'{skim_tag}_skims') + if isinstance(file_names, dict): + for i in ("file", "files", "omx"): + if i in file_names: + file_names = file_names[i] + break + if isinstance(file_names, dict): + raise ValueError(f"must specify `{skim_tag}_skims.file` in network_los settings file") file_names = [file_names] if isinstance(file_names, str) else file_names return file_names + def zarr_file_name(self, skim_tag): + """ + Return zarr directory name from network_los settings file for the specified skim_tag (e.g. 'taz') + + Parameters + ---------- + skim_tag: str (e.g. 'taz') + + Returns + ------- + list of str + """ + skim_setting = self.setting(f'{skim_tag}_skims') + if isinstance(skim_setting, dict): + return skim_setting.get("zarr", None) + else: + return None + + def skim_backing_store(self, skim_tag): + return self.setting(f'{skim_tag}_skims.backend', f"shared_memory_{skim_tag}") + + def skim_max_float_precision(self, skim_tag): + return self.setting(f'{skim_tag}_skims.max_float_precision', 32) + + def skim_digital_encoding(self, skim_tag): + return self.setting(f'{skim_tag}_skims.digital_encoding', []) + def multiprocess(self): """ return True if this is a multiprocessing run (even if it is a main or single-process subprocess) @@ -481,6 +530,7 @@ def get_skim_dict(self, skim_tag): assert skim_tag in self.skim_dicts, \ f"network_los.get_skim_dict: skim tag '{skim_tag}' not in skim_dicts" + return self.skim_dicts[skim_tag] def get_default_skim_dict(self): @@ -492,7 +542,13 @@ def get_default_skim_dict(self): TAZ SkimDict for ONE_ZONE, MazSkimDict for TWO_ZONE and THREE_ZONE """ if self.zone_system == ONE_ZONE: - return self.get_skim_dict('taz') + sharrow_enabled = config.setting("sharrow", False) + if sharrow_enabled: + skim_dataset = inject.get_injectable('skim_dataset') + from .skim_dataset import SkimDataset + return SkimDataset(skim_dataset) + else: + return self.get_skim_dict('taz') else: return self.get_skim_dict('maz') @@ -558,7 +614,7 @@ def skim_time_period_label(self, time_period): Returns ------- - numpy.array + pandas Series string time period labels """ @@ -574,9 +630,16 @@ def skim_time_period_label(self, time_period): assert 0 == model_time_window_min % period_minutes total_periods = model_time_window_min / period_minutes - bins = np.digitize( - [np.array(time_period) % total_periods], self.skim_time_periods['periods'], right=True)[0] - 1 - return np.array(self.skim_time_periods['labels'])[bins] + # FIXME - eventually test and use np version always? + if np.isscalar(time_period): + bin = np.digitize([time_period % total_periods], + self.skim_time_periods['periods'], right=True)[0] - 1 + result = self.skim_time_periods['labels'][bin] + else: + result = pd.cut(time_period, self.skim_time_periods['periods'], + labels=self.skim_time_periods['labels'], ordered=False).astype(str) + + return result def get_tazs(self): # FIXME - should compute on init? diff --git a/activitysim/core/mem.py b/activitysim/core/mem.py index fd8c906b2..4798ef5af 100644 --- a/activitysim/core/mem.py +++ b/activitysim/core/mem.py @@ -254,6 +254,10 @@ def shared_memory_size(data_buffers=None): data_buffers = inject.get_injectable('data_buffers', {}) for k, data_buffer in data_buffers.items(): + if isinstance(data_buffer, str) and data_buffer.startswith("sh.Dataset:"): + from sharrow import Dataset + shared_size += Dataset.preload_shared_memory_size(data_buffer[11:]) + continue try: obj = data_buffer.get_obj() except Exception: diff --git a/activitysim/core/mp_tasks.py b/activitysim/core/mp_tasks.py index 6365a5b27..5c40bf062 100644 --- a/activitysim/core/mp_tasks.py +++ b/activitysim/core/mp_tasks.py @@ -1307,15 +1307,18 @@ def skip_phase(phase): def find_breadcrumb(crumb, default=None): return old_breadcrumbs.get(step_name, {}).get(crumb, default) + sharrow_enabled = config.setting("sharrow", False) + # - allocate shared data shared_data_buffers = {} mem.trace_memory_info("allocate_shared_skim_buffer.before") t0 = tracing.print_elapsed_time() - shared_data_buffers.update(allocate_shared_skim_buffers()) - t0 = tracing.print_elapsed_time('allocate shared skim buffer', t0) - mem.trace_memory_info("allocate_shared_skim_buffer.completed") + if not sharrow_enabled: + shared_data_buffers.update(allocate_shared_skim_buffers()) + t0 = tracing.print_elapsed_time('allocate shared skim buffer', t0) + mem.trace_memory_info("allocate_shared_skim_buffer.completed") # combine shared_skim_buffer and shared_shadow_pricing_buffer in shared_data_buffer t0 = tracing.print_elapsed_time() @@ -1323,15 +1326,29 @@ def find_breadcrumb(crumb, default=None): t0 = tracing.print_elapsed_time('allocate shared shadow_pricing buffer', t0) mem.trace_memory_info("allocate_shared_shadow_pricing_buffers.completed") + if sharrow_enabled: + shared_data_buffers["skim_dataset"] = "sh.Dataset:skim_dataset" + + # Loading skim_dataset must be done in the main process, not a subprocess, + # so that this min process can hold on to the shared memory and then cleanly + # release it on exit. + from . import flow # make injectable known + inject.get_injectable('skim_dataset') + + t0 = tracing.print_elapsed_time('setup skim_dataset', t0) + mem.trace_memory_info("skim_dataset.completed") + # - mp_setup_skims - if len(shared_data_buffers) > 0: - run_sub_task( - multiprocessing.Process( - target=mp_setup_skims, name='mp_setup_skims', args=(injectables,), - kwargs=shared_data_buffers) - ) - t0 = tracing.print_elapsed_time('setup shared_data_buffers', t0) - mem.trace_memory_info("mp_setup_skims.completed") + if not sharrow_enabled: + if len(shared_data_buffers) > 0: + run_sub_task( + multiprocessing.Process( + target=mp_setup_skims, name='mp_setup_skims', args=(injectables,), + kwargs=shared_data_buffers) + ) + + t0 = tracing.print_elapsed_time('setup shared_data_buffers', t0) + mem.trace_memory_info("mp_setup_skims.completed") # - for each step in run list for step_info in run_list['multiprocess_steps']: diff --git a/activitysim/core/pipeline.py b/activitysim/core/pipeline.py index 1b2240e6d..93576c93a 100644 --- a/activitysim/core/pipeline.py +++ b/activitysim/core/pipeline.py @@ -81,6 +81,14 @@ def is_open(): return _PIPELINE.is_open +def is_readonly(): + if is_open(): + store = get_pipeline_store() + if store and store._mode == 'r': + return True + return False + + def pipeline_table_key(table_name, checkpoint_name): if checkpoint_name: key = f"{table_name}/{checkpoint_name}" @@ -101,7 +109,7 @@ def close_open_files(): _PIPELINE.open_files.clear() -def open_pipeline_store(overwrite=False): +def open_pipeline_store(overwrite=False, mode='a'): """ Open the pipeline checkpoint store @@ -109,6 +117,17 @@ def open_pipeline_store(overwrite=False): ---------- overwrite : bool delete file before opening (unless resuming) + mode : {'a', 'w', 'r', 'r+'}, default 'a' + ``'r'`` + Read-only; no data can be modified. + ``'w'`` + Write; a new file is created (an existing file with the same + name would be deleted). + ``'a'`` + Append; an existing file is opened for reading and writing, + and if the file does not exist it is created. + ``'r+'`` + It is similar to ``'a'``, but the file must already exist. """ if _PIPELINE.pipeline_store is not None: @@ -125,7 +144,7 @@ def open_pipeline_store(overwrite=False): print(e) logger.warning("Error removing %s: %s" % (pipeline_file_path, e)) - _PIPELINE.pipeline_store = pd.HDFStore(pipeline_file_path, mode='a') + _PIPELINE.pipeline_store = pd.HDFStore(pipeline_file_path, mode=mode) logger.debug(f"opened pipeline_store {pipeline_file_path}") @@ -354,8 +373,10 @@ def load_checkpoint(checkpoint_name): i = checkpoints[checkpoints[CHECKPOINT_NAME] == checkpoint_name].index[0] checkpoints = checkpoints.loc[:i] + # if the store is not open in read-only mode, # write it to the store to ensure so any subsequent checkpoints are forgotten - write_df(checkpoints, CHECKPOINT_TABLE_NAME) + if not is_readonly(): + write_df(checkpoints, CHECKPOINT_TABLE_NAME) except IndexError: msg = "Couldn't find checkpoint '%s' in checkpoints" % (checkpoint_name,) @@ -473,7 +494,26 @@ def run_model(model_name): t0 = print_elapsed_time() logger.info(f"#run_model running step {step_name}") - orca.run([step_name]) + instrument = config.setting('instrument', None) + if instrument is not None: + try: + from pyinstrument import Profiler + except ImportError: + instrument = False + if isinstance(instrument, (list, set, tuple)): + if step_name not in instrument: + instrument = False + else: + instrument = True + + if instrument: + with Profiler() as profiler: + orca.run([step_name]) + out_file = config.profiling_file_path(f"{step_name}.html") + with open(out_file, "wt") as f: + f.write(profiler.output_html()) + else: + orca.run([step_name]) t0 = print_elapsed_time("#run_model completed step '%s'" % model_name, t0, debug=True) mem.trace_memory_info(f"pipeline.run_model {model_name} finished") @@ -487,7 +527,7 @@ def run_model(model_name): logger.info("##### skipping %s checkpoint for %s" % (step_name, model_name)) -def open_pipeline(resume_after=None): +def open_pipeline(resume_after=None, mode='a'): """ Start pipeline, either for a new run or, if resume_after, loading checkpoint from pipeline. @@ -498,6 +538,9 @@ def open_pipeline(resume_after=None): ---------- resume_after : str or None name of checkpoint to load from pipeline store + mode : {'a', 'w', 'r', 'r+'}, default 'a' + same as for typical opening of H5Store. Ignored unless resume_after + is not None. This is here to allow read-only pipeline for benchmarking. """ if is_open(): @@ -511,7 +554,7 @@ def open_pipeline(resume_after=None): if resume_after: # open existing pipeline logger.debug("open_pipeline - open existing pipeline") - open_pipeline_store(overwrite=False) + open_pipeline_store(overwrite=False, mode=mode) load_checkpoint(resume_after) else: # open new, empty pipeline diff --git a/activitysim/core/random.py b/activitysim/core/random.py index a9c977034..f809cf7c7 100644 --- a/activitysim/core/random.py +++ b/activitysim/core/random.py @@ -248,7 +248,7 @@ def random_for_df(self, df, step_name, n=1): self.row_states.loc[df.index, 'offset'] += n return rands - def normal_for_df(self, df, step_name, mu, sigma, lognormal=False): + def normal_for_df(self, df, step_name, mu, sigma, lognormal=False, size=None): """ Return a floating point random number in normal (or lognormal) distribution for each row in df using the appropriate random channel for each row. @@ -296,11 +296,11 @@ def to_series(x): if lognormal: rands = \ - np.asanyarray([prng.lognormal(mean=mu[i], sigma=sigma[i]) + np.asanyarray([prng.lognormal(mean=mu[i], sigma=sigma[i], size=size) for i, prng in enumerate(generators)]) else: rands = \ - np.asanyarray([prng.normal(loc=mu[i], scale=sigma[i]) + np.asanyarray([prng.normal(loc=mu[i], scale=sigma[i], size=size) for i, prng in enumerate(generators)]) # update offset for rows we handled @@ -602,7 +602,7 @@ def random_for_df(self, df, n=1): rands = channel.random_for_df(df, self.step_name, n) return rands - def normal_for_df(self, df, mu=0, sigma=1, broadcast=False): + def normal_for_df(self, df, mu=0, sigma=1, broadcast=False, size=None): """ Return a single floating point normal random number in range (-inf, inf) for each row in df using the appropriate random channel for each row. @@ -640,11 +640,14 @@ def normal_for_df(self, df, mu=0, sigma=1, broadcast=False): if broadcast: alts_df = df df = df.index.unique().to_series() - rands = channel.normal_for_df(df, self.step_name, mu=0, sigma=1, lognormal=False) - rands = reindex(pd.Series(rands, index=df.index), alts_df.index) + rands = channel.normal_for_df(df, self.step_name, mu=0, sigma=1, lognormal=False, size=size) + if size is not None: + rands = reindex(pd.DataFrame(rands, index=df.index), alts_df.index) + else: + rands = reindex(pd.Series(rands, index=df.index), alts_df.index) rands = rands*sigma + mu else: - rands = channel.normal_for_df(df, self.step_name, mu, sigma, lognormal=False) + rands = channel.normal_for_df(df, self.step_name, mu, sigma, lognormal=False, size=size) return rands diff --git a/activitysim/core/simulate.py b/activitysim/core/simulate.py index c14939da9..691bbc9e5 100644 --- a/activitysim/core/simulate.py +++ b/activitysim/core/simulate.py @@ -3,12 +3,16 @@ from builtins import range +import os import warnings import logging from collections import OrderedDict import numpy as np +import orca import pandas as pd +import time +from datetime import timedelta from . import logit from . import tracing @@ -19,14 +23,10 @@ from . import chunk from . import pathbuilder +from .simulate_consts import * logger = logging.getLogger(__name__) -SPEC_DESCRIPTION_NAME = 'Description' -SPEC_EXPRESSION_NAME = 'Expression' -SPEC_LABEL_NAME = 'Label' - -ALT_LOSER_UTIL = -900 def random_rows(df, n): @@ -373,6 +373,11 @@ def eval_coefficients(spec, coefficients, estimator): continue spec[c] = spec[c].apply(lambda x: eval(str(x), {}, coefficients)).astype(np.float32) + sharrow_enabled = config.setting("sharrow", False) + if sharrow_enabled: + # keep all zero rows, reduces the number of unique flows to compile and store. + return spec + # drop any rows with all zeros since they won't have any effect (0 marginal utility) # (do not drop rows in estimation mode as it may confuse the estimation package (e.g. larch) zero_rows = (spec == 0).all(axis=1) @@ -386,6 +391,9 @@ def eval_coefficients(spec, coefficients, estimator): return spec +_OLD_TIME = 0 +_NEW_TIME = 0 + def eval_utilities(spec, choosers, locals_d=None, trace_label=None, have_trace_targets=False, trace_all_rows=False, estimator=None, trace_column_names=None, log_alt_losers=False): @@ -413,79 +421,130 @@ def eval_utilities(spec, choosers, locals_d=None, trace_label=None, ------- """ + start_time = time.time() + + global _OLD_TIME, _NEW_TIME + + # from .flow import apply_flow # need import here to make injectable available + # from . import inject + # skim_dataset = inject.get_injectable('skim_dataset') + sharrow_enabled = config.setting("sharrow", False) + + if trace_label and ( + # TODO: make this smarter + # trace_label.startswith("trip_mode_choice") # uses '.isin(I_RIDE_HAIL_MODES)' + # or + trace_label.startswith("joint_tour_composition") + or trace_label.startswith("stop_frequency.social") + or trace_label.startswith("stop_frequency.shopping") + or trace_label.startswith("stop_frequency.eatout") + # or trace_label.startswith("trip_destination") + ): + sharrow_enabled = False + + expression_values = None + + t0 = time.time() + from .flow import TimeLogger + timelogger = TimeLogger("simulate") + sh_util = None + sh_flow = None + utilities = None + + if sharrow_enabled: + from .flow import apply_flow # import inside func to prevent circular imports + locals_dict = {} + locals_dict.update(config.get_global_constants()) + if locals_d is not None: + locals_dict.update(locals_d) + sh_util, sh_flow, sh_flow_idxs = apply_flow(spec, choosers, locals_dict, trace_label, sharrow_enabled == 'require') + chunk.log_df(trace_label, "sh_flow_idxs", sh_flow_idxs) + del sh_flow_idxs + chunk.log_df(trace_label, "sh_flow_idxs", None) + utilities = sh_util + timelogger.mark("sharrow flow", True, logger, trace_label) + else: + timelogger.mark("sharrow flow", False) + t1 = time.time() # fixme - restore tracing and _check_for_variability - trace_label = tracing.extend_trace_label(trace_label, 'eval_utils') + if utilities is None or estimator or sharrow_enabled == 'test': - # avoid altering caller's passed-in locals_d parameter (they may be looping) - locals_dict = assign.local_utilities() + trace_label = tracing.extend_trace_label(trace_label, 'eval_utils') - if locals_d is not None: - locals_dict.update(locals_d) - globals_dict = {} + # avoid altering caller's passed-in locals_d parameter (they may be looping) + locals_dict = assign.local_utilities() - locals_dict['df'] = choosers + if locals_d is not None: + locals_dict.update(locals_d) + globals_dict = {} - # - eval spec expressions - if isinstance(spec.index, pd.MultiIndex): - # spec MultiIndex with expression and label - exprs = spec.index.get_level_values(SPEC_EXPRESSION_NAME) - else: - exprs = spec.index + locals_dict['df'] = choosers - expression_values = np.empty((spec.shape[0], choosers.shape[0])) - chunk.log_df(trace_label, "expression_values", expression_values) + # - eval spec expressions + if isinstance(spec.index, pd.MultiIndex): + # spec MultiIndex with expression and label + exprs = spec.index.get_level_values(SPEC_EXPRESSION_NAME) + else: + exprs = spec.index - i = 0 - for expr, coefficients in zip(exprs, spec.values): + expression_values = np.empty((spec.shape[0], choosers.shape[0])) + chunk.log_df(trace_label, "expression_values", expression_values) - try: - with warnings.catch_warnings(record=True) as w: - # Cause all warnings to always be triggered. - warnings.simplefilter("always") - if expr.startswith('@'): - expression_value = eval(expr[1:], globals_dict, locals_dict) + i = 0 + for expr, coefficients in zip(exprs, spec.values): - else: - expression_value = choosers.eval(expr) + try: + with warnings.catch_warnings(record=True) as w: + # Cause all warnings to always be triggered. + warnings.simplefilter("always") + if expr.startswith('@'): + expression_value = eval(expr[1:], globals_dict, locals_dict) + else: + expression_value = choosers.eval(expr) - if len(w) > 0: - for wrn in w: - logger.warning(f"{trace_label} - {type(wrn).__name__} ({wrn.message}) evaluating: {str(expr)}") + if len(w) > 0: + for wrn in w: + logger.warning(f"{trace_label} - {type(wrn).__name__} ({wrn.message}) evaluating: {str(expr)}") - except Exception as err: - logger.exception(f"{trace_label} - {type(err).__name__} ({str(err)}) evaluating: {str(expr)}") - raise err + except Exception as err: + logger.exception(f"{trace_label} - {type(err).__name__} ({str(err)}) evaluating: {str(expr)}") + raise err - if log_alt_losers: - # utils for each alt for this expression - # FIXME if we always did tis, we cold uem these and skip np.dot below - utils = np.outer(expression_value, coefficients) - losers = np.amax(utils, axis=1) < ALT_LOSER_UTIL + if log_alt_losers: + # utils for each alt for this expression + # FIXME if we always did tis, we cold uem these and skip np.dot below + utils = np.outer(expression_value, coefficients) + losers = np.amax(utils, axis=1) < ALT_LOSER_UTIL - if losers.any(): - logger.warning(f"{trace_label} - {sum(losers)} choosers of {len(losers)} " - f"with prohibitive utilities for all alternatives for expression: {expr}") + if losers.any(): + logger.warning(f"{trace_label} - {sum(losers)} choosers of {len(losers)} " + f"with prohibitive utilities for all alternatives for expression: {expr}") - expression_values[i] = expression_value - i += 1 + expression_values[i] = expression_value + i += 1 - chunk.log_df(trace_label, "expression_values", expression_values) + chunk.log_df(trace_label, "expression_values", expression_values) - if estimator: - df = pd.DataFrame( - data=expression_values.transpose(), - index=choosers.index, - columns=spec.index.get_level_values(SPEC_LABEL_NAME)) - df.index.name = choosers.index.name - estimator.write_expression_values(df) + if estimator: + df = pd.DataFrame( + data=expression_values.transpose(), + index=choosers.index, + columns=spec.index.get_level_values(SPEC_LABEL_NAME)) + df.index.name = choosers.index.name + estimator.write_expression_values(df) - # - compute_utilities - utilities = np.dot(expression_values.transpose(), spec.astype(np.float64).values) - utilities = pd.DataFrame(data=utilities, index=choosers.index, columns=spec.columns) + # - compute_utilities + utilities = np.dot(expression_values.transpose(), spec.astype(np.float64).values) + timelogger.mark("simple flow", True, logger=logger, suffix=trace_label) + else: + timelogger.mark("simple flow", False) + + utilities = pd.DataFrame(data=utilities, index=choosers.index, columns=spec.columns) chunk.log_df(trace_label, "utilities", utilities) + timelogger.mark("assemble utilities") # sometimes tvpb will drop rows on the fly and we wind up with an empty # table of choosers. this will just bypass tracing in that case. @@ -500,6 +559,21 @@ def eval_utilities(spec, choosers, locals_d=None, trace_label=None, # get int offsets of the trace_targets (offsets of bool=True values) offsets = np.nonzero(list(trace_targets))[0] + # trace sharrow + if sh_flow is not None: + try: + data_sh = sh_flow.load( + sh_flow.shared_data.replace_datasets( + df=choosers.iloc[offsets], + ), + dtype=np.float32, + ) + expression_values_sh = pd.DataFrame(data=data_sh.T, index=spec.index) + except ValueError: + expression_values_sh = None + else: + expression_values_sh = None + # get array of expression_values # expression_values.shape = (len(spec), len(choosers)) # data.shape = (len(spec), len(offsets)) @@ -513,6 +587,9 @@ def eval_utilities(spec, choosers, locals_d=None, trace_label=None, trace_column_names = [trace_column_names] expression_values_df.columns = pd.MultiIndex.from_frame(choosers.loc[trace_targets, trace_column_names]) + if expression_values_sh is not None: + tracing.trace_df(expression_values_sh, tracing.extend_trace_label(trace_label, 'expression_values_sh'), + slicer=None, transpose=False) tracing.trace_df(expression_values_df, tracing.extend_trace_label(trace_label, 'expression_values'), slicer=None, transpose=False) @@ -524,6 +601,7 @@ def eval_utilities(spec, choosers, locals_d=None, trace_label=None, tracing.trace_df(expression_values_df.multiply(spec[c].values, axis=0), tracing.extend_trace_label(trace_label, name), slicer=None, transpose=False) + timelogger.mark("trace", True, logger, trace_label) del expression_values chunk.log_df(trace_label, "expression_values", None) @@ -531,6 +609,34 @@ def eval_utilities(spec, choosers, locals_d=None, trace_label=None, # no longer our problem - but our caller should re-log this... chunk.log_df(trace_label, "utilities", None) + if sharrow_enabled == 'test': + try: + np.testing.assert_allclose( + sh_util, utilities.values, rtol=1e-2, atol=0, + err_msg='utility not aligned', verbose=True, + ) + except AssertionError as err: + print(err) + misses = np.where(~np.isclose(sh_util, utilities.values, rtol=1e-2, atol=0)) + _sh_util_miss1 = sh_util[tuple(m[0] for m in misses)] + _u_miss1 = utilities.values[tuple(m[0] for m in misses)] + diff = _sh_util_miss1 - _u_miss1 + if len(misses[0]) > sh_util.size*0.01: + print(f"big problem: {len(misses[0])} missed close values out of {sh_util.size} ({100*len(misses[0]) / sh_util.size:.2f}%)") + print(f"{sh_util.shape=}") + print(misses) + raise + except TypeError as err: + print(err) + print("sh_util") + print(sh_util) + print("utilities") + print(utilities) + timelogger.mark("sharrow test", True, logger, trace_label) + + end_time = time.time() + logger.info(f"simulate.eval_utils runtime: {timedelta(seconds=end_time - start_time)} {trace_label}") + timelogger.summary(logger, "simulate.eval_utils timing") return utilities diff --git a/activitysim/core/simulate_consts.py b/activitysim/core/simulate_consts.py new file mode 100644 index 000000000..8e56376da --- /dev/null +++ b/activitysim/core/simulate_consts.py @@ -0,0 +1,5 @@ +SPEC_DESCRIPTION_NAME = 'Description' +SPEC_EXPRESSION_NAME = 'Expression' +SPEC_LABEL_NAME = 'Label' + +ALT_LOSER_UTIL = -900 diff --git a/activitysim/core/skim_dataset.py b/activitysim/core/skim_dataset.py new file mode 100644 index 000000000..5e557e4cd --- /dev/null +++ b/activitysim/core/skim_dataset.py @@ -0,0 +1,326 @@ +import pandas as pd +import xarray as xr +import numpy as np +import logging +from sharrow import array_decode + +from . import flow as __flow + +logger = logging.getLogger(__name__) + +POSITIONS_AS_DICT = True + +def _iat(source, *, _names=None, _load=False, _index_name=None, **idxs): + loaders = {} + if _index_name is None: + _index_name = "index" + for k, v in idxs.items(): + loaders[k] = xr.DataArray(v, dims=[_index_name]) + if _names: + ds = source[_names] + else: + ds = source + if _load: + ds = ds.load() + return ds.isel(**loaders) + + +def _at(source, *, _names=None, _load=False, _index_name=None, **idxs): + loaders = {} + if _index_name is None: + _index_name = "index" + for k, v in idxs.items(): + loaders[k] = xr.DataArray(v, dims=[_index_name]) + if _names: + ds = source[_names] + else: + ds = source + if _load: + ds = ds.load() + return ds.sel(**loaders) + + +def gather(source, indexes): + """ + Extract values by label on the coordinates indicated by columns of a DataFrame. + + Parameters + ---------- + source : xarray.DataArray or xarray.Dataset + The source of the values to extract. + indexes : Mapping[str, array-like] + The keys of `indexes` (if given as a dataframe, the column names) + should match the named dimensions of `source`. The resulting extracted + data will have a shape one row per row of `df`, and columns matching + the data variables in `source`, and each value is looked up by the labels. + + Returns + ------- + pd.DataFrame + """ + result = _at(source, **indexes).reset_coords(drop=True) + return result + + +def igather(source, positions): + """ + Extract values by position on the coordinates indicated by columns of a DataFrame. + + Parameters + ---------- + source : xarray.DataArray or xarray.Dataset + positions : pd.DataFrame or Mapping[str, array-like] + The columns (or keys) of `df` should match the named dimensions of + this Dataset. The resulting extracted DataFrame will have one row + per row of `df`, columns matching the data variables in this dataset, + and each value is looked up by the positions. + + Returns + ------- + pd.DataFrame + """ + result = _iat(source, **positions).reset_coords(drop=True) + return result + + +class SkimDataset: + + def __init__(self, dataset): + self.dataset = dataset + self.time_map = {j: i for i, j in enumerate(self.dataset.indexes['time_period'])} + self.usage = set() # track keys of skims looked up + + def get_skim_usage(self): + """ + return set of keys of skims looked up. e.g. {'DIST', 'SOV'} + + Returns + ------- + set: + """ + return self.usage + + def wrap(self, orig_key, dest_key): + """ + return a SkimWrapper for self + """ + return DatasetWrapper(self.dataset, orig_key, dest_key, time_map=self.time_map) + + def wrap_3d(self, orig_key, dest_key, dim3_key): + """ + return a SkimWrapper for self + """ + return DatasetWrapper(self.dataset, orig_key, dest_key, dim3_key, time_map=self.time_map) + + def lookup(self, orig, dest, key): + self.usage.add(key) + use_index = None + + if use_index is None and hasattr(orig, 'index'): + use_index = orig.index + if use_index is None and hasattr(dest, 'index'): + use_index = dest.index + + orig = np.asanyarray(orig).astype(int) + dest = np.asanyarray(dest).astype(int) + + # TODO offset mapper if required + positions = {'otaz':orig, 'dtaz':dest} + + # When asking for a particular time period + if isinstance(key, tuple) and len(key) == 2: + main_key, time_key = key + if time_key in self.time_map: + positions['time_period'] = np.full_like(orig, self.time_map[time_key]) + key = main_key + else: + raise KeyError(key) + + result = igather(self.dataset[key], positions) + + if 'digital_encoding' in self.dataset[key].attrs: + result = array_decode(result, self.dataset[key].attrs['digital_encoding']) + + result = result.to_series() + + if use_index is not None: + result.index = use_index + return result + + def map_time_periods_from_series(self, time_period_labels): + logger.info(f"vectorize lookup for time_period={time_period_labels.name}") + time_period_idxs = pd.Series( + np.vectorize(self.time_map.get)(time_period_labels), + index=time_period_labels.index, + ) + return time_period_idxs + + +class DatasetWrapper: + + def __init__(self, dataset, orig_key, dest_key, time_key=None, *, time_map=None): + """ + + Parameters + ---------- + skim_dict: SkimDict + + orig_key: str + name of column in dataframe to use as implicit orig for lookups + dest_key: str + name of column in dataframe to use as implicit dest for lookups + """ + self.dataset = dataset + self.orig_key = orig_key + self.dest_key = dest_key + self.time_key = time_key + self.df = None + if time_map is None: + self.time_map = {j: i for i, j in enumerate(self.dataset.indexes['time_period'])} + else: + self.time_map = time_map + + def map_time_periods(self, df): + if self.time_key: + logger.info(f"vectorize lookup for time_period={self.time_key}") + time_period_idxs = pd.Series( + np.vectorize(self.time_map.get)(df[self.time_key]), + index=df.index, + ) + return time_period_idxs + + def set_df(self, df): + """ + Set the dataframe + + Parameters + ---------- + df : DataFrame + The dataframe which contains the origin and destination ids + + Returns + ------- + self (to facilitate chaining) + """ + assert self.orig_key in df, f"orig_key '{self.orig_key}' not in df columns: {list(df.columns)}" + assert self.dest_key in df, f"dest_key '{self.dest_key}' not in df columns: {list(df.columns)}" + if self.time_key: + assert self.time_key in df, f"time_key '{self.time_key}' not in df columns: {list(df.columns)}" + self.df = df + + # TODO allow offsets if needed + positions = { + 'otaz': df[self.orig_key], + 'dtaz': df[self.dest_key], + } + if self.time_key: + if np.issubdtype(df[self.time_key].dtype, np.integer) and df[self.time_key].max() < self.dataset.dims['time_period']: + logger.info(f"natural use for time_period={self.time_key}") + positions['time_period'] = df[self.time_key] + else: + logger.info(f"vectorize lookup for time_period={self.time_key}") + positions['time_period'] = pd.Series( + np.vectorize(self.time_map.get)(df[self.time_key]), + index=df.index, + ) + + if POSITIONS_AS_DICT: + self.positions = {} + for k, v in positions.items(): + self.positions[k] = v.astype(int) + else: + self.positions = pd.DataFrame(positions).astype(int) + + return self + + def lookup(self, key, reverse=False): + """ + Generally not called by the user - use __getitem__ instead + + Parameters + ---------- + key : hashable + The key (identifier) for this skim object + + od : bool (optional) + od=True means lookup standard origin-destination skim value + od=False means lookup destination-origin skim value + + Returns + ------- + impedances: pd.Series + A Series of impedances which are elements of the Skim object and + with the same index as df + """ + + assert self.df is not None, "Call set_df first" + if reverse: + if isinstance(self.positions, dict): + x = self.positions.copy() + x.update({ + 'otaz': self.positions['dtaz'], + 'dtaz': self.positions['otaz'], + }) + else: + x = self.positions.rename(columns={'otaz': 'dtaz', 'dtaz': 'otaz'}) + else: + if isinstance(self.positions, dict): + x = self.positions.copy() + else: + x = self.positions + + # When asking for a particular time period + if isinstance(key, tuple) and len(key) == 2: + main_key, time_key = key + if time_key in self.time_map: + if isinstance(x, dict): + x['time_period'] = np.full_like(x['otaz'], fill_value=self.time_map[time_key]) + else: + x = x.assign(time_period=self.time_map[time_key]) + key = main_key + else: + raise KeyError(key) + + result = igather(self.dataset[key], x) + if 'digital_encoding' in self.dataset[key].attrs: + result = array_decode(result, self.dataset[key].attrs['digital_encoding']) + + # Return a series, consistent with ActivitySim SkimWrapper + return result.to_series() + + def reverse(self, key): + """ + return skim value in reverse (d-o) direction + """ + return self.lookup(key, reverse=True) + + def max(self, key): + """ + return max skim value in either o-d or d-o direction + """ + assert self.df is not None, "Call set_df first" + + s = np.maximum( + self.lookup(key), + self.lookup(key, True), + ) + + return pd.Series(s, index=self.df.index) + + def __getitem__(self, key): + """ + Get the lookup for an available skim object (df and orig/dest and column names implicit) + + Parameters + ---------- + key : hashable + The key (identifier) for the skim object + + Returns + ------- + impedances: pd.Series with the same index as df + A Series of impedances values from the single Skim with specified key, indexed byt orig/dest pair + """ + return self.lookup(key) + + diff --git a/activitysim/core/skim_dict_factory.py b/activitysim/core/skim_dict_factory.py index 51b1bc59a..acba205c4 100644 --- a/activitysim/core/skim_dict_factory.py +++ b/activitysim/core/skim_dict_factory.py @@ -122,7 +122,8 @@ def load_skim_info(self, skim_tag): if self.omx_shape is None: self.omx_shape = tuple(int(i) for i in omx_file.shape()) # sometimes omx shape are floats! else: - assert (self.omx_shape == tuple(int(i) for i in omx_file.shape())) + assert (self.omx_shape == tuple(int(i) for i in omx_file.shape())), \ + f"Mismatch shape {self.omx_shape} != {omx_file.shape()}" for skim_name in omx_file.listMatrices(): assert skim_name not in self.omx_manifest, \ diff --git a/activitysim/core/skim_dictionary.py b/activitysim/core/skim_dictionary.py index cdd32b552..26ce94212 100644 --- a/activitysim/core/skim_dictionary.py +++ b/activitysim/core/skim_dictionary.py @@ -259,8 +259,10 @@ def _lookup(self, orig, dest, block_offsets): # print(f"in_skim\n{in_skim}") # check for bad indexes (other than NOT_IN_SKIM_ZONE_ID) - assert (in_skim | (orig == NOT_IN_SKIM_ZONE_ID) | (dest == NOT_IN_SKIM_ZONE_ID)).all(), \ - f"{(~in_skim).sum()} od pairs not in skim" + if not (in_skim | (orig == NOT_IN_SKIM_ZONE_ID) | (dest == NOT_IN_SKIM_ZONE_ID)).all(): + raise AssertionError( + f"{(~in_skim).sum()} od pairs not in skim including [{orig[~in_skim][:5]}]->[{dest[~in_skim][:5]}]" + ) if not in_skim.all(): result = np.where(in_skim, result, NOT_IN_SKIM_NAN).astype(self.dtype) @@ -817,3 +819,9 @@ def get(self, row_ids, col_ids): result = pd.Series(result, index=row_ids.index) return result + + def get_rows(self, row_ids): + return self.offset_mapper.map(np.asanyarray(row_ids)) + + def get_cols(self, col_ids): + return np.vectorize(self.cols_to_indexes.get)(col_ids) diff --git a/activitysim/core/steps/output.py b/activitysim/core/steps/output.py index bf0c43948..2333a836c 100644 --- a/activitysim/core/steps/output.py +++ b/activitysim/core/steps/output.py @@ -39,7 +39,12 @@ def track_skim_usage(output_dir): for key in skim_dict.get_skim_usage(): print(key, file=output_file) - unused = set(k for k in skim_dict.skim_info.base_keys) - set(k for k in skim_dict.get_skim_usage()) + try: + unused = set(k for k in skim_dict.skim_info.base_keys) - set(k for k in skim_dict.get_skim_usage()) + except AttributeError: + base_keys = set(skim_dict.dataset.variables.keys()) - set(skim_dict.dataset.coords.keys()) + # using dataset + unused = base_keys - set(k for k in skim_dict.get_skim_usage()) for key in unused: print(key, file=output_file) diff --git a/activitysim/core/test/test_logit.py b/activitysim/core/test/test_logit.py index 15db8a5ae..cac8b19e9 100644 --- a/activitysim/core/test/test_logit.py +++ b/activitysim/core/test/test_logit.py @@ -105,7 +105,8 @@ def test_make_choices_only_one(): pdt.assert_series_equal( choices, - pd.Series([0, 1], index=['x', 'y'])) + pd.Series([0, 1], index=['x', 'y']), + check_dtype=False) def test_make_choices_real_probs(utilities): @@ -114,7 +115,9 @@ def test_make_choices_real_probs(utilities): pdt.assert_series_equal( choices, - pd.Series([1, 2], index=[0, 1])) + pd.Series([1, 2], index=[0, 1]), + check_dtype=False, + ) @pytest.fixture(scope='module') diff --git a/activitysim/core/test/test_simulate.py b/activitysim/core/test/test_simulate.py index 3fbee00db..89ae95d7a 100644 --- a/activitysim/core/test/test_simulate.py +++ b/activitysim/core/test/test_simulate.py @@ -81,7 +81,7 @@ def test_simple_simulate(data, spec): choices = simulate.simple_simulate(choosers=data, spec=spec, nest_spec=None) expected = pd.Series([1, 1, 1], index=data.index) - pdt.assert_series_equal(choices, expected) + pdt.assert_series_equal(choices, expected, check_dtype=False) def test_simple_simulate_chunked(data, spec): @@ -90,4 +90,4 @@ def test_simple_simulate_chunked(data, spec): choices = simulate.simple_simulate(choosers=data, spec=spec, nest_spec=None, chunk_size=2) expected = pd.Series([1, 1, 1], index=data.index) - pdt.assert_series_equal(choices, expected) + pdt.assert_series_equal(choices, expected, check_dtype=False) diff --git a/activitysim/core/timetable.py b/activitysim/core/timetable.py index a79373834..d490218f8 100644 --- a/activitysim/core/timetable.py +++ b/activitysim/core/timetable.py @@ -8,6 +8,8 @@ import numpy as np import pandas as pd +import xarray as xr +import numba as nb from activitysim.core import pipeline from activitysim.core import chunk @@ -37,7 +39,7 @@ ] COLLISION_LIST = [a + (b << I_BIT_SHIFT) for a, b in COLLISIONS] - +COLLISION_ARRAY = np.asarray(COLLISION_LIST) # str versions of time windows period states C_EMPTY = str(I_EMPTY) @@ -47,6 +49,73 @@ C_START_END = str(I_START_END) +@nb.njit +def _fast_tour_available( + tdds, + tdd_footprints, + window_row_ids, + window_row_ix__mapper, + self_windows, +): + """ + + Parameters + ---------- + tdds : array-like, shape (k) + tdd_footprints : array-like, shape (c, t) + window_row_ids : array-like, shape (k) + window_row_ix__mapper : FastMapping._mapper + self_windows : array-like + + Returns + ------- + array of bool, shape (k) + """ + out = np.ones_like(tdds, dtype=np.bool_) + for k in range(tdds.shape[0]): + tour_footprints = tdd_footprints[tdds[k]] # -> shape (t) + row_ix = window_row_ix__mapper[window_row_ids[k]] + windows = self_windows[row_ix] + x = tour_footprints + (windows << I_BIT_SHIFT) + stop = False + for j in range(COLLISION_ARRAY.size): + for i in range(x.size): + if x[i] == COLLISION_ARRAY[j]: + out[k] = False + stop = True + break + if stop: + break + return out + + +@nb.njit +def _available_run_length( + available, + before, + periods, + time_ix_mapper, +): + num_rows = available.shape[0] + num_cols = available.shape[1] + _time_col_ix_map = np.arange(num_cols) + available_run_length = np.zeros(num_rows, dtype=np.int32) + for row in range(num_rows): + _time_col_ix = time_ix_mapper[periods[row]] # scalar + if before: + mask = (_time_col_ix_map < _time_col_ix) * 1 + # index of first unavailable window after time + first_unavailable = np.where((1 - available[row]) * mask, _time_col_ix_map, 0).max() + available_run_length[row] = _time_col_ix - first_unavailable - 1 + else: + # ones after specified time, zeroes before + mask = (_time_col_ix_map > _time_col_ix) * 1 + # index of first unavailable window after time + first_unavailable = np.where((1 - available[row]) * mask, _time_col_ix_map, num_cols).min() + available_run_length[row] = first_unavailable - _time_col_ix - 1 + return available_run_length + + def tour_map(persons, tours, tdd_alts, persons_id_col='person_id'): sigil = { @@ -194,10 +263,15 @@ def __init__(self, windows_df, tdd_alts_df, table_name=None): self.checkpoint_df = None # series to map window row index value to window row's ordinal index - self.window_row_ix = pd.Series(list(range(len(windows_df.index))), index=windows_df.index) + from ..core.fast_mapping import FastMapping + self.window_row_ix = FastMapping( + pd.Series(list(range(len(windows_df.index))), index=windows_df.index) + ) int_time_periods = [int(c) for c in windows_df.columns.values] - self.time_ix = pd.Series(list(range(len(windows_df.columns))), index=int_time_periods) + self.time_ix = FastMapping( + pd.Series(list(range(len(windows_df.columns))), index=int_time_periods) + ) # - pre-compute window state footprints for every tdd_alt min_period = min(int_time_periods) @@ -242,7 +316,7 @@ def slice_windows_by_row_id(self, window_row_ids): return windows array slice containing rows for specified window_row_ids (in window_row_ids order) """ - row_ixs = window_row_ids.map(self.window_row_ix).values + row_ixs = self.window_row_ix.apply_to(window_row_ids.values) windows = self.windows[row_ixs] return windows @@ -250,10 +324,10 @@ def slice_windows_by_row_id(self, window_row_ids): def slice_windows_by_row_id_and_period(self, window_row_ids, periods): # row ixs of tour_df group rows in windows - row_ixs = window_row_ids.map(self.window_row_ix).values + row_ixs = self.window_row_ix.apply_to(window_row_ids) # col ixs of periods in windows - time_col_ixs = periods.map(self.time_ix).values + time_col_ixs = self.time_ix.apply_to(periods) windows = self.windows[row_ixs, time_col_ixs] @@ -305,22 +379,31 @@ def tour_available(self, window_row_ids, tdds): available : pandas Series of bool with same index as window_row_ids.index (presumably tour_id, but we don't care) """ - - assert len(window_row_ids) == len(tdds) - - # numpy array with one tdd_footprints_df row for tdds - tour_footprints = self.tdd_footprints[tdds.values.astype(int)] - - # numpy array with one windows row for each person - windows = self.slice_windows_by_row_id(window_row_ids) - - # t0 = tracing.print_elapsed_time("slice_windows_by_row_id", t0, debug=True) - - x = tour_footprints + (windows << I_BIT_SHIFT) - - available = ~np.isin(x, COLLISION_LIST).any(axis=1) - available = pd.Series(available, index=window_row_ids.index) - + available = _fast_tour_available( + tdds.astype(int), + self.tdd_footprints, + window_row_ids.astype(int).to_numpy(), + self.window_row_ix._mapper, + self.windows, + ) + + # assert len(window_row_ids) == len(tdds) + # + # # numpy array with one tdd_footprints_df row for tdds + # tour_footprints = self.tdd_footprints[tdds.values.astype(int)] + # + # # numpy array with one windows row for each person + # windows = self.slice_windows_by_row_id(window_row_ids) + # + # # t0 = tracing.print_elapsed_time("slice_windows_by_row_id", t0, debug=True) + # + # x = tour_footprints + (windows << I_BIT_SHIFT) + # + # available = ~np.isin(x, COLLISION_LIST).any(axis=1) + # if isinstance(window_row_ids, pd.Series): + # available = pd.Series(available, index=window_row_ids.index) + # elif isinstance(window_row_ids, xr.DataArray): + # available = xr.DataArray(available, dims=window_row_ids.dims, coords=window_row_ids.coords) return available def assign(self, window_row_ids, tdds): @@ -347,7 +430,7 @@ def assign(self, window_row_ids, tdds): tour_footprints = self.tdd_footprints[tdds.values.astype(int)] # row idxs of windows to assign to - row_ixs = window_row_ids.map(self.window_row_ix).values + row_ixs = self.window_row_ix.apply_to(window_row_ids) self.windows[row_ixs] = np.bitwise_or(self.windows[row_ixs], tour_footprints) @@ -384,7 +467,7 @@ def assign_subtour_mask(self, window_row_ids, tdds): tour_footprints = self.tdd_footprints[tdds.values.astype(int)] # row idxs of windows to assign to - row_ixs = window_row_ids.map(self.window_row_ix).values + row_ixs = self.window_row_ix.apply_to(window_row_ids) self.windows[row_ixs] = (tour_footprints == 0) * I_MIDDLE @@ -412,7 +495,7 @@ def assign_footprints(self, window_row_ids, footprints): assert len(window_row_ids.values) == len(np.unique(window_row_ids.values)) # row idxs of windows to assign to - row_ixs = window_row_ids.map(self.window_row_ix).values + row_ixs = self.window_row_ix.apply_to(window_row_ids) self.windows[row_ixs] = np.bitwise_or(self.windows[row_ixs], footprints) @@ -448,8 +531,8 @@ def adjacent_window_run_length(self, window_row_ids, periods, before): trace_label = 'tt.adjacent_window_run_length' with chunk.chunk_log(trace_label): - time_col_ixs = periods.map(self.time_ix).values - chunk.log_df(trace_label, 'time_col_ixs', time_col_ixs) + # time_col_ixs = self.time_ix.apply_to(periods).to_numpy() + # chunk.log_df(trace_label, 'time_col_ixs', time_col_ixs) # sliced windows with 1s where windows state is I_MIDDLE and 0s elsewhere available = (self.slice_windows_by_row_id(window_row_ids) != I_MIDDLE) * 1 @@ -459,29 +542,38 @@ def adjacent_window_run_length(self, window_row_ids, periods, before): available[:, 0] = 0 available[:, -1] = 0 - # column idxs of windows - num_rows, num_cols = available.shape - time_col_ix_map = np.tile(np.arange(0, num_cols), num_rows).reshape(num_rows, num_cols) - # 0 1 2 3 4 5... - # 0 1 2 3 4 5... - # 0 1 2 3 4 5... - chunk.log_df(trace_label, 'time_col_ix_map', time_col_ix_map) - - if before: - # ones after specified time, zeroes before - mask = (time_col_ix_map < time_col_ixs.reshape(num_rows, 1)) * 1 - # index of first unavailable window after time - first_unavailable = np.where((1-available)*mask, time_col_ix_map, 0).max(axis=1) - available_run_length = time_col_ixs - first_unavailable - 1 - else: - # ones after specified time, zeroes before - mask = (time_col_ix_map > time_col_ixs.reshape(num_rows, 1)) * 1 - # index of first unavailable window after time - first_unavailable = np.where((1 - available) * mask, time_col_ix_map, num_cols).min(axis=1) - available_run_length = first_unavailable - time_col_ixs - 1 - - chunk.log_df(trace_label, 'mask', mask) - chunk.log_df(trace_label, 'first_unavailable', first_unavailable) + available_run_length = _available_run_length( + available, + before, + periods.to_numpy(), + self.time_ix._mapper, + ) + + # # column idxs of windows + # num_rows, num_cols = available.shape + # time_col_ix_map = np.tile(np.arange(0, num_cols), num_rows).reshape(num_rows, num_cols) + # # 0 1 2 3 4 5... + # # 0 1 2 3 4 5... + # # 0 1 2 3 4 5... + # chunk.log_df(trace_label, 'time_col_ix_map', time_col_ix_map) + # # START MYSTERY RAM + # + # if before: + # # ones after specified time, zeroes before + # mask = (time_col_ix_map < time_col_ixs.reshape(num_rows, 1)) * 1 + # # index of first unavailable window after time + # first_unavailable = np.where((1-available)*mask, time_col_ix_map, 0).max(axis=1) + # available_run_length = time_col_ixs - first_unavailable - 1 + # else: + # # ones after specified time, zeroes before + # mask = (time_col_ix_map > time_col_ixs.reshape(num_rows, 1)) * 1 + # # index of first unavailable window after time + # first_unavailable = np.where((1 - available) * mask, time_col_ix_map, num_cols).min(axis=1) + # available_run_length = first_unavailable - time_col_ixs - 1 + # + # # END MYSTERY RAM + # chunk.log_df(trace_label, 'mask', mask) + # chunk.log_df(trace_label, 'first_unavailable', first_unavailable) chunk.log_df(trace_label, 'available_run_length', available_run_length) return pd.Series(available_run_length, index=window_row_ids.index) diff --git a/activitysim/core/tracing.py b/activitysim/core/tracing.py index 271fc5728..702cdd852 100644 --- a/activitysim/core/tracing.py +++ b/activitysim/core/tracing.py @@ -30,6 +30,18 @@ logger = logging.getLogger(__name__) +class ElapsedTimeFormatter(logging.Formatter): + def format(self, record): + duration_milliseconds = record.relativeCreated + hours, rem = divmod(duration_milliseconds / 1000, 3600) + minutes, seconds = divmod(rem, 60) + if hours: + record.elapsedTime = ("{:0>2}:{:0>2}:{:05.2f}".format(int(hours), int(minutes), seconds)) + else: + record.elapsedTime = ("{:0>2}:{:05.2f}".format(int(minutes), seconds)) + return super(ElapsedTimeFormatter, self).format(record) + + def extend_trace_label(trace_label, extension): if trace_label: trace_label = "%s.%s" % (trace_label, extension) @@ -63,9 +75,15 @@ def log_runtime(model_name, start_time=None, timing=None): process_name = multiprocessing.current_process().name - # only log runtime for locutor - if config.setting('multiprocess', False) and not inject.get_injectable('locutor', False): - return + if config.setting('multiprocess', False): + # when benchmarking, log timing for each processes in its own log + if config.setting('benchmarking', False): + header = "component_name,duration" + with config.open_log_file(f'timing_log.{process_name}.csv', 'a', header) as log_file: + print(f"{model_name},{timing}", file=log_file) + # only continue to log runtime in global timing log for locutor + if not inject.get_injectable('locutor', False): + return header = "process_name,model_name,seconds,minutes" with config.open_log_file('timing_log.csv', 'a', header) as log_file: diff --git a/activitysim/core/util.py b/activitysim/core/util.py index 1351023f3..d561100e4 100644 --- a/activitysim/core/util.py +++ b/activitysim/core/util.py @@ -170,14 +170,21 @@ def reindex(series1, series2): """ - # turns out the merge is much faster than the .loc below - df = pd.merge(series2.to_frame(name='left'), - series1.to_frame(name='right'), - left_on="left", - right_index=True, - how="left") - return df.right - + result = series1.reindex(series2) + try: + result.index = series2.index + except AttributeError: + pass + return result + + # # turns out the merge is much faster than the .loc below + # df = pd.merge(series2.to_frame(name='left'), + # series1.to_frame(name='right'), + # left_on="left", + # right_index=True, + # how="left") + # return df.right + # # return pd.Series(series1.loc[series2.values].values, index=series2.index) diff --git a/activitysim/estimation/larch/location_choice.py b/activitysim/estimation/larch/location_choice.py index 5188737c2..e842105b4 100644 --- a/activitysim/estimation/larch/location_choice.py +++ b/activitysim/estimation/larch/location_choice.py @@ -287,46 +287,53 @@ def update_size_spec(model, data, result_dir=Path('.'), output_file=None): return master_size_spec -def workplace_location_model(return_data=False): +def workplace_location_model(**kwargs): + unused = kwargs.pop('name', None) return location_choice_model( name="workplace_location", - return_data=return_data, + **kwargs, ) -def school_location_model(return_data=False): +def school_location_model(**kwargs): + unused = kwargs.pop('name', None) return location_choice_model( name="school_location", - return_data=return_data, + **kwargs, ) -def atwork_subtour_destination_model(return_data=False): +def atwork_subtour_destination_model(**kwargs): + unused = kwargs.pop('name', None) return location_choice_model( name="atwork_subtour_destination", - return_data=return_data, + **kwargs, ) -def joint_tour_destination_model(return_data=False): +def joint_tour_destination_model(**kwargs): # goes with non_mandatory_tour_destination + unused = kwargs.pop('name', None) + if 'coefficients_file' not in kwargs: + kwargs['coefficients_file'] = "non_mandatory_tour_destination_coefficients.csv" return location_choice_model( name="joint_tour_destination", - coefficients_file="non_mandatory_tour_destination_coefficients.csv", - return_data=return_data, + **kwargs, ) -def non_mandatory_tour_destination_model(return_data=False): +def non_mandatory_tour_destination_model(**kwargs): # goes with joint_tour_destination + unused = kwargs.pop('name', None) return location_choice_model( name="non_mandatory_tour_destination", - return_data=return_data, + **kwargs, ) -def trip_destination_model(return_data=False): +def trip_destination_model(**kwargs): + unused = kwargs.pop('name', None) return location_choice_model( name="trip_destination", - return_data=return_data, + **kwargs, ) diff --git a/activitysim/examples/__init__.py b/activitysim/examples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/activitysim/examples/example_manifest.yaml b/activitysim/examples/example_manifest.yaml index cd2f8ea44..1f636dffd 100644 --- a/activitysim/examples/example_manifest.yaml +++ b/activitysim/examples/example_manifest.yaml @@ -32,6 +32,11 @@ include: - example_mtc/configs - example_mtc/configs_mp + - example_mtc/configs_sh + - example_mtc/configs_sh_compile + - example_mtc/configs_chunktrain + - example_mtc/configs_production + # example_mtc/data # load data from activitysim_resources instead - example_mtc/output - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/mtc_data_full/skims.omx data/skims.omx @@ -615,6 +620,8 @@ - example_sandag/configs_1_zone - example_sandag/data_1 - example_sandag/output_1 + optimize: + - patch_example_sandag_1_zone - name: example_sandag_1_zone_full description: full 1-zone example for the SANDAG region @@ -626,7 +633,7 @@ - example_sandag/../example_mtc/configs example_mtc - example_sandag/configs_1_zone - - example_sandag/data_1 + # example_sandag/data_1 # load data from activitysim_resources instead - example_sandag/output_1 - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_1_zone_data_full/households.csv data_1/households.csv @@ -679,7 +686,7 @@ - example_sandag/../example_psrc/configs example_psrc - example_sandag/configs_2_zone - - example_sandag/data_2 + # example_sandag/data_2 # load data from activitysim_resources instead - example_sandag/output_2 - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_2_zone_data_full/households.csv data_2/households.csv @@ -742,12 +749,15 @@ # activitysim run -c configs_3_zone -c example_mtc/configs -d data_3 -o output_3 -s settings_mp.yaml # cd .. include: - - example_sandag/data_3 + # example_sandag/data_3 # load data from activitysim_resources instead - example_sandag/../example_mtc/configs example_mtc - example_sandag/configs_3_zone - example_sandag/configs_skip_accessibility + - example_sandag/configs_benchmarking - example_sandag/output_3 + - example_sandag/data_3/cached_accessibility.csv.gz + data_3/cached_accessibility.csv.gz - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_3_zone_data_full/taz_skims1.omx data_3/taz_skims1.omx 5b56d0e79ec671e37f8c71f7fedd741d7bf32d2bced866ab1f03f3973fccce8c @@ -805,56 +815,3 @@ - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_3_zone_data_full/tap_lines.csv data_3/tap_lines.csv 0e1b2c532e5e85b48e2ac77b2836be7ec0cc7cba79907c6f5fb11d2ba171230a - -- name: example_sandag_xborder - description: SANDAG cross border travel model - # activitysim create -e example_sandag_xborder -d test_example_sandag_xborder - # cd test_example_sandag_xborder - # python simulation.py - # cd .. - include: - - example_sandag_xborder/configs - - example_sandag_xborder/data - - example_sandag_xborder/extensions - - example_sandag_xborder/output - - example_sandag_xborder/simulation.py - -- name: example_sandag_xborder_full - description: full scale SANDAG cross border travel model - # activitysim create -e example_sandag_xborder_full -d test_example_sandag_xborder_full - # cd test_example_sandag_xborder_full - # python simulation.py - # cd .. - include: - - example_sandag_xborder/configs - - example_sandag_xborder/extensions - - example_sandag_xborder/output - - example_sandag_xborder/simulation.py - - https://raw.githubusercontent.com/ActivitySim/activitysim_resources/master/sandag_xborder/households_xborder.csv - data/households_xborder.csv - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/maz_maz_walk.csv - data/maz_maz_walk.csv - - https://raw.githubusercontent.com/activitysim/activitysim_resources/master/sandag_xborder/maz_tap_walk.csv - data/maz_tap_walk.csv - - https://raw.githubusercontent.com/activitysim/activitysim_resources/master/sandag_xborder/mazs_xborder.csv - data/mazs_xborder.csv - - https://raw.githubusercontent.com/activitysim/activitysim_resources/master/sandag_xborder/persons_xborder.csv - data/persons_xborder.csv - - https://raw.githubusercontent.com/activitysim/activitysim_resources/master/sandag_xborder/tap_lines.csv - data/tap_lines.csv - - https://raw.githubusercontent.com/activitysim/activitysim_resources/master/sandag_xborder/taps.csv - data/taps.csv - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/tours_xborder.csv - data/tours_xborder.csv - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/transit_skims_xborder.omx - data/transit_skims_xborder.omx - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/traffic_skims_xborder_AM.omx - data/traffic_skims_xborder_AM.omx - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/traffic_skims_xborder_EA.omx - data/traffic_skims_xborder_EA.omx - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/traffic_skims_xborder_EV.omx - data/traffic_skims_xborder_EV.omx - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/traffic_skims_xborder_MD.omx - data/traffic_skims_xborder_MD.omx - - https://media.githubusercontent.com/media/activitysim/activitysim_resources/master/sandag_xborder/traffic_skims_xborder_PM.omx - data/traffic_skims_xborder_PM.omx \ No newline at end of file diff --git a/activitysim/examples/example_mtc/configs/logging.yaml b/activitysim/examples/example_mtc/configs/logging.yaml index 71ac15cc1..9f250d3c7 100644 --- a/activitysim/examples/example_mtc/configs/logging.yaml +++ b/activitysim/examples/example_mtc/configs/logging.yaml @@ -24,6 +24,12 @@ logging: handlers: [console, logfile] propagate: false + filelock: + level: WARN + + sharrow: + level: INFO + handlers: logfile: @@ -36,7 +42,7 @@ logging: console: class: logging.StreamHandler stream: ext://sys.stdout - formatter: simpleFormatter + formatter: elapsedFormatter level: NOTSET formatters: @@ -52,3 +58,7 @@ logging: format: '%(asctime)s - %(levelname)s - %(name)s - %(message)s' datefmt: '%d/%m/%Y %H:%M:%S' + elapsedFormatter: + (): activitysim.core.tracing.ElapsedTimeFormatter + format: '[{elapsedTime}] {levelname:s}: {message:s}' + style: '{' diff --git a/activitysim/examples/example_mtc/configs/settings.yaml b/activitysim/examples/example_mtc/configs/settings.yaml index 543992d83..3639d3aec 100644 --- a/activitysim/examples/example_mtc/configs/settings.yaml +++ b/activitysim/examples/example_mtc/configs/settings.yaml @@ -26,6 +26,8 @@ input_table_list: workers: num_workers VEHICL: auto_ownership TAZ: home_zone_id + recode_columns: + home_zone_id: land_use.zone_id keep_columns: - home_zone_id - income @@ -58,6 +60,8 @@ input_table_list: rename_columns: TAZ: zone_id # person_id is the required index column COUNTY: county_id + recode_columns: + zone_id: zero-based keep_columns: - DISTRICT - SD @@ -90,7 +94,7 @@ input_table_list: #input_store: ../output/input_data.h5 # number of households to simulate -households_sample_size: 100 +households_sample_size: 100000 # simulate all households # households_sample_size: 0 @@ -155,7 +159,7 @@ keep_mem_logs: True # trace household id; comment out or leave empty for no trace # households with all tour types # [ 728370 1234067 1402924 1594625 1595333 1747572 1896849 1931818 2222690 2344951 2677154] -trace_hh_id: 982875 +trace_hh_id: # trace origin, destination in accessibility calculation; comment out or leave empty for no trace # trace_od: [5, 11] @@ -243,3 +247,6 @@ household_median_value_of_time: 2: 8.81 3: 10.44 4: 12.86 + +sharrow: test +offset_preprocessing: True diff --git a/activitysim/examples/example_mtc/configs/trip_destination.csv b/activitysim/examples/example_mtc/configs/trip_destination.csv index f1cbdf094..7da5531fb 100644 --- a/activitysim/examples/example_mtc/configs/trip_destination.csv +++ b/activitysim/examples/example_mtc/configs/trip_destination.csv @@ -1,8 +1,8 @@ Label,Description,Expression,work,univ,school,escort,shopping,eatout,othmaint,social,othdiscr,atwork local_dist_od,,_od_DIST@od_skims['DIST'],1,1,1,1,1,1,1,1,1,1 local_dist_dp,,_dp_DIST@dp_skims['DIST'],1,1,1,1,1,1,1,1,1,1 -util_size_term,size term,"@np.log1p(size_terms.get(df.dest_taz, df.purpose))",coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one -util_no_attractions,no attractions,"@size_terms.get(df.dest_taz, df.purpose) == 0",coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE +util_size_term,size term,"@np.log1p(size_terms.get(df.dest_taz, df.purpose)) # sharrow: np.log1p(size_terms['arry'])",coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one,coef_one +util_no_attractions,no attractions,"@size_terms.get(df.dest_taz, df.purpose) == 0 # sharrow: size_terms['arry'] == 0",coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE,coef_UNAVAILABLE util_stop_zone_CDB_are_type,#stop zone CBD area type,"@reindex(land_use.area_type, df.dest_taz) < setting('cbd_threshold')",,,,,,,,,, util_distance_inbound,distance (calibration adjustment individual - inbound),@(~df.is_joint & ~df.outbound) * (_od_DIST + _dp_DIST),coef_util_distance_work_outbound,coef_util_distance_univ,coef_util_distance_school,coef_util_distance_escort,coef_util_distance_shopping,coef_util_distance_eatout,coef_util_distance_othmaint,coef_util_distance_social,coef_util_distance_othdiscr,coef_util_distance_atwork util_distance_outbound,distance (calibration adjustment individual - outbound),@(~df.is_joint & df.outbound) * (_od_DIST + _dp_DIST),coef_util_distance_work_inbound,coef_util_distance_univ,coef_util_distance_school,coef_util_distance_escort,coef_util_distance_shopping,coef_util_distance_eatout,coef_util_distance_othmaint,coef_util_distance_social,coef_util_distance_othdiscr,coef_util_distance_atwork diff --git a/activitysim/examples/example_mtc/configs/trip_destination_annotate_trips_preprocessor.csv b/activitysim/examples/example_mtc/configs/trip_destination_annotate_trips_preprocessor.csv index 1a1afb074..9f2d502d3 100644 --- a/activitysim/examples/example_mtc/configs/trip_destination_annotate_trips_preprocessor.csv +++ b/activitysim/examples/example_mtc/configs/trip_destination_annotate_trips_preprocessor.csv @@ -7,4 +7,7 @@ Description,Target,Expression #,,not needed as school is not chosen as an intermediate trip destination #,_grade_school,"(df.primary_purpose == 'school') & reindex(persons.is_gradeschool, df.person_id)" #,size_segment,"df.primary_purpose.where(df.primary_purpose != 'school', np.where(_grade_school,'gradeschool', 'highschool'))" -,tour_leg_dest,"np.where(df.outbound,reindex(tours.destination, df.tour_id), reindex(tours.origin, df.tour_id))" \ No newline at end of file +,purpose_index_num,"size_terms.get_cols(df.purpose)" +,tour_mode_is_walk,"reindex(tours.tour_mode, df.tour_id)=='WALK'" +,tour_mode_is_bike,"reindex(tours.tour_mode, df.tour_id)=='BIKE'" +,tour_leg_dest,"np.where(df.outbound,reindex(tours.destination, df.tour_id), reindex(tours.origin, df.tour_id)).astype(int)" diff --git a/activitysim/examples/example_mtc/configs/trip_destination_sample.csv b/activitysim/examples/example_mtc/configs/trip_destination_sample.csv index 7a350c3d9..85a114ab4 100644 --- a/activitysim/examples/example_mtc/configs/trip_destination_sample.csv +++ b/activitysim/examples/example_mtc/configs/trip_destination_sample.csv @@ -1,13 +1,13 @@ Description,Expression,work,univ,school,escort,shopping,eatout,othmaint,social,othdiscr,atwork ,_od_DIST@od_skims['DIST'],1,1,1,1,1,1,1,1,1,1 ,_dp_DIST@dp_skims['DIST'],1,1,1,1,1,1,1,1,1,1 -Not available if walk tour not within walking distance,@(df.tour_mode=='WALK') & (od_skims['DISTWALK'] > max_walk_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 -Not available if walk tour not within walking distance,@(df.tour_mode=='WALK') & (dp_skims['DISTWALK'] > max_walk_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 -Not available if bike tour not within biking distance,@(df.tour_mode=='BIKE') & (od_skims['DISTBIKE'] > max_bike_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 -Not available if bike tour not within biking distance,@(df.tour_mode=='BIKE') & (dp_skims['DISTBIKE'] > max_bike_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 +Not available if walk tour not within walking distance,@(df.tour_mode_is_walk) & (od_skims['DISTWALK'] > max_walk_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 +Not available if walk tour not within walking distance,@(df.tour_mode_is_walk) & (dp_skims['DISTWALK'] > max_walk_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 +Not available if bike tour not within biking distance,@(df.tour_mode_is_bike) & (od_skims['DISTBIKE'] > max_bike_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 +Not available if bike tour not within biking distance,@(df.tour_mode_is_bike) & (dp_skims['DISTBIKE'] > max_bike_distance),-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 #If transit tour is not in walk sub-zone it must be walkable,,,,,,,,,,, -size term,"@np.log1p(size_terms.get(df.dest_taz, df.purpose))",1,1,1,1,1,1,1,1,1,1 -no attractions,"@size_terms.get(df.dest_taz, df.purpose) == 0",-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 +size term,"@np.log1p(size_terms.get(df.dest_taz, df.purpose)) # sharrow: np.log1p(size_terms['arry'])",1,1,1,1,1,1,1,1,1,1 +no attractions,"@size_terms.get(df.dest_taz, df.purpose) == 0 # sharrow: size_terms['arry'] == 0",-999,-999,-999,-999,-999,-999,-999,-999,-999,-999 #stop zone CBD area type,"@reindex(land_use.area_type, df.dest_taz) < setting('cbd_threshold')",,,,,,,,,, distance (calibration adjustment individual - inbound),@(~df.is_joint & ~df.outbound) * (_od_DIST + _dp_DIST),-0.04972591574229,-0.0613,-0.1056,-0.1491,-0.1192,-0.1029,-0.0962,-0.1329,-0.126172224,-0.122334597 distance (calibration adjustment individual - outbound),@(~df.is_joint & df.outbound) * (_od_DIST + _dp_DIST),0.147813278663948,-0.0613,-0.1056,-0.1491,-0.1192,-0.1029,-0.0962,-0.1329,-0.126172224,-0.122334597 diff --git a/activitysim/examples/example_mtc/configs/trip_mode_choice.csv b/activitysim/examples/example_mtc/configs/trip_mode_choice.csv index 2da242e95..5b701bf67 100644 --- a/activitysim/examples/example_mtc/configs/trip_mode_choice.csv +++ b/activitysim/examples/example_mtc/configs/trip_mode_choice.csv @@ -330,18 +330,18 @@ util_tour_mode_is_walk_transit,Walk to Transit tour mode availability,tour_mode_ util_tour_mode_is_drive_transit,Drive to Transit tour modes availability,tour_mode_is_drive_transit,-999,-999,-999,-999,-999,-999,-999,-999,-999,-999,-999,-999,-999,,,,,,,, util_tour_mode_is_ride_hail,Ride hail tour modes availability,tour_mode_is_ride_hail,-999,-999,,,,,,-999,,,,,,-999,-999,-999,-999,-999,,, ,#indiv tour ASCs,,,,,,,,,,,,,,,,,,,,,, -util_Drive_Alone_tour_mode_ASC_shared_ride_2_df_is_indiv,Drive Alone tour mode ASC -- shared ride 2,@(df.is_indiv & df.i_tour_mode.isin(I_SOV_MODES)),,,sov_ASC_sr2,sov_ASC_sr2,,,,,,,,,,,,,,,,, -util_Drive_Alone_tour_mode_ASC_shared_ride_3_plus,Drive Alone tour mode ASC -- shared ride 3+,@(df.is_indiv & df.i_tour_mode.isin(I_SOV_MODES)),,,,,sov_ASC_sr3p,sov_ASC_sr3p,,,,,,,,,,,,,,, -util_Drive_Alone_tour_mode_ASC_walk,Drive Alone tour mode ASC -- walk,@(df.is_indiv & df.i_tour_mode.isin(I_SOV_MODES)),,,,,,,sov_ASC_walk,,,,,,,,,,,,,, -util_Drive_Alone_tour_mode_ASC_ride_hail,Drive Alone tour mode ASC -- ride hail,@(df.is_indiv & df.i_tour_mode.isin(I_SOV_MODES)),,,,,,,,,,,,,,,,,,,sov_ASC_rh,sov_ASC_rh,sov_ASC_rh -util_Shared_Ride_2_tour_mode_ASC_shared_ride_2,Shared Ride 2 tour mode ASC -- shared ride 2,@(df.is_indiv & df.i_tour_mode.isin(I_SR2_MODES)),,,sr2_ASC_sr2,sr2_ASC_sr2,,,,,,,,,,,,,,,,, -util_Shared_Ride_2_tour_mode_ASC_shared_ride_3_plus,Shared Ride 2 tour mode ASC -- shared ride 3+,@(df.is_indiv & df.i_tour_mode.isin(I_SR2_MODES)),,,,,sr2_ASC_sr3p,sr2_ASC_sr3p,,,,,,,,,,,,,,, -util_Shared_Ride_2_tour_mode_ASC_walk,Shared Ride 2 tour mode ASC -- walk,@(df.is_indiv & df.i_tour_mode.isin(I_SR2_MODES)),,,,,,,sr2_ASC_walk,,,,,,,,,,,,,, -util_Shared_Ride_2_tour_mode_ASC_ride_hail,Shared Ride 2 tour mode ASC -- ride hail,@(df.is_indiv & df.i_tour_mode.isin(I_SR2_MODES)),,,,,,,,,,,,,,,,,,,sr2_ASC_rh,sr2_ASC_rh,sr2_ASC_rh -util_Shared_Ride_3_tour_mode_ASC_shared_ride_2,Shared Ride 3+ tour mode ASC -- shared ride 2,@(df.is_indiv & df.i_tour_mode.isin(I_SR3P_MODES)),,,sr3p_ASC_sr2,sr3p_ASC_sr2,,,,,,,,,,,,,,,,, -util_Shared_Ride_3_tour_mode_ASC_shared_ride_3_plus,Shared Ride 3+ tour mode ASC -- shared ride 3+,@(df.is_indiv & df.i_tour_mode.isin(I_SR3P_MODES)),,,,,sr3p_ASC_sr3p,sr3p_ASC_sr3p,,,,,,,,,,,,,,, -util_Shared_Ride_3_tour_mode_ASC_walk,Shared Ride 3+ tour mode ASC -- walk,@(df.is_indiv & df.i_tour_mode.isin(I_SR3P_MODES)),,,,,,,sr3p_ASC_walk,,,,,,,,,,,,,, -util_Shared_Ride_3_tour_mode_ASC_ride_hail,Shared Ride 3+ tour mode ASC -- ride hail,@(df.is_indiv & df.i_tour_mode.isin(I_SR3P_MODES)),,,,,,,,,,,,,,,,,,,sr3p_ASC_rh,sr3p_ASC_rh,sr3p_ASC_rh +util_Drive_Alone_tour_mode_ASC_shared_ride_2_df_is_indiv,Drive Alone tour mode ASC -- shared ride 2,@(df.is_indiv & df.tour_mode_is_SOV),,,sov_ASC_sr2,sov_ASC_sr2,,,,,,,,,,,,,,,,, +util_Drive_Alone_tour_mode_ASC_shared_ride_3_plus,Drive Alone tour mode ASC -- shared ride 3+,@(df.is_indiv & df.tour_mode_is_SOV),,,,,sov_ASC_sr3p,sov_ASC_sr3p,,,,,,,,,,,,,,, +util_Drive_Alone_tour_mode_ASC_walk,Drive Alone tour mode ASC -- walk,@(df.is_indiv & df.tour_mode_is_SOV),,,,,,,sov_ASC_walk,,,,,,,,,,,,,, +util_Drive_Alone_tour_mode_ASC_ride_hail,Drive Alone tour mode ASC -- ride hail,@(df.is_indiv & df.tour_mode_is_SOV),,,,,,,,,,,,,,,,,,,sov_ASC_rh,sov_ASC_rh,sov_ASC_rh +util_Shared_Ride_2_tour_mode_ASC_shared_ride_2,Shared Ride 2 tour mode ASC -- shared ride 2,@(df.is_indiv & df.tour_mode_is_SR2),,,sr2_ASC_sr2,sr2_ASC_sr2,,,,,,,,,,,,,,,,, +util_Shared_Ride_2_tour_mode_ASC_shared_ride_3_plus,Shared Ride 2 tour mode ASC -- shared ride 3+,@(df.is_indiv & df.tour_mode_is_SR2),,,,,sr2_ASC_sr3p,sr2_ASC_sr3p,,,,,,,,,,,,,,, +util_Shared_Ride_2_tour_mode_ASC_walk,Shared Ride 2 tour mode ASC -- walk,@(df.is_indiv & df.tour_mode_is_SR2),,,,,,,sr2_ASC_walk,,,,,,,,,,,,,, +util_Shared_Ride_2_tour_mode_ASC_ride_hail,Shared Ride 2 tour mode ASC -- ride hail,@(df.is_indiv & df.tour_mode_is_SR2),,,,,,,,,,,,,,,,,,,sr2_ASC_rh,sr2_ASC_rh,sr2_ASC_rh +util_Shared_Ride_3_tour_mode_ASC_shared_ride_2,Shared Ride 3+ tour mode ASC -- shared ride 2,@(df.is_indiv & df.tour_mode_is_SR3P),,,sr3p_ASC_sr2,sr3p_ASC_sr2,,,,,,,,,,,,,,,,, +util_Shared_Ride_3_tour_mode_ASC_shared_ride_3_plus,Shared Ride 3+ tour mode ASC -- shared ride 3+,@(df.is_indiv & df.tour_mode_is_SR3P),,,,,sr3p_ASC_sr3p,sr3p_ASC_sr3p,,,,,,,,,,,,,,, +util_Shared_Ride_3_tour_mode_ASC_walk,Shared Ride 3+ tour mode ASC -- walk,@(df.is_indiv & df.tour_mode_is_SR3P),,,,,,,sr3p_ASC_walk,,,,,,,,,,,,,, +util_Shared_Ride_3_tour_mode_ASC_ride_hail,Shared Ride 3+ tour mode ASC -- ride hail,@(df.is_indiv & df.tour_mode_is_SR3P),,,,,,,,,,,,,,,,,,,sr3p_ASC_rh,sr3p_ASC_rh,sr3p_ASC_rh util_Walk_tour_mode_ASC_ride_hail,Walk tour mode ASC -- ride hail,@df.is_indiv & (df.i_tour_mode == I_WALK_MODE),,,,,,,,,,,,,,,,,,,walk_ASC_rh,walk_ASC_rh,walk_ASC_rh util_Bike_tour_mode_ASC_walk,Bike tour mode ASC -- walk,@df.is_indiv & (df.i_tour_mode == I_BIKE_MODE),,,,,,,bike_ASC_walk,,,,,,,,,,,,,, util_Bike_tour_mode_ASC_ride_hail,Bike tour mode ASC -- ride hail,@df.is_indiv & (df.i_tour_mode == I_BIKE_MODE),,,,,,,,,,,,,,,,,,,bike_ASC_rh,bike_ASC_rh,bike_ASC_rh @@ -364,15 +364,15 @@ util_Ride_Hail_tour_mode_ASC_shared_ride_2,Ride Hail tour mode ASC -- shared rid util_Ride_Hail_tour_mode_ASC_shared_ride_3_plus,Ride Hail tour mode ASC -- shared ride 3+,@(df.is_indiv & df.tour_mode_is_ride_hail),,,,,ride_hail_ASC_sr3p,ride_hail_ASC_sr3p,,,,,,,,,,,,,,, util_Ride_Hail_tour_mode_ASC_walk,Ride Hail tour mode ASC -- walk,@(df.is_indiv & df.tour_mode_is_ride_hail),,,,,,,ride_hail_ASC_walk,,,,,,,,,,,,,, util_Ride_Hail_tour_mode_ASC_walk_to_transit,Ride Hail tour mode ASC -- walk to transit,@(df.is_indiv & df.tour_mode_is_ride_hail),,,,,,,,,ride_hail_ASC_walk_transit,ride_hail_ASC_walk_transit,ride_hail_ASC_walk_transit,ride_hail_ASC_walk_transit,ride_hail_ASC_walk_transit,,,,,,,, -util_Ride_Hail_tour_mode_ASC_ride_hail_taxi,Ride Hail tour mode ASC -- ride hail,@(df.is_indiv & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,,,,,,,,,,,,,ride_hail_ASC_taxi,, -util_Ride_Hail_tour_mode_ASC_ride_hail_single,Ride Hail tour mode ASC -- ride hail,@(df.is_indiv & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,,,,,,,,,,,,,,ride_hail_ASC_tnc_single, -util_Ride_Hail_tour_mode_ASC_ride_hail_shared,Ride Hail tour mode ASC -- ride hail,@(df.is_indiv & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,,,,,,,,,,,,,,,ride_hail_ASC_tnc_shared +util_Ride_Hail_tour_mode_ASC_ride_hail_taxi,Ride Hail tour mode ASC -- ride hail,@(df.is_indiv & df.tour_mode_is_ride_hail),,,,,,,,,,,,,,,,,,,ride_hail_ASC_taxi,, +util_Ride_Hail_tour_mode_ASC_ride_hail_single,Ride Hail tour mode ASC -- ride hail,@(df.is_indiv & df.tour_mode_is_ride_hail),,,,,,,,,,,,,,,,,,,,ride_hail_ASC_tnc_single, +util_Ride_Hail_tour_mode_ASC_ride_hail_shared,Ride Hail tour mode ASC -- ride hail,@(df.is_indiv & df.tour_mode_is_ride_hail),,,,,,,,,,,,,,,,,,,,,ride_hail_ASC_tnc_shared #,joint tour ASCs,,,,,,,,,,,,,,,,,,,,,, -util_joint_auto_tour_mode_ASC_shared_ride_2,joint - auto tour mode ASC -- shared ride 2,@(df.is_joint & df.i_tour_mode.isin(I_AUTO_MODES)),,,joint_auto_ASC_sr2,joint_auto_ASC_sr2,,,,,,,,,,,,,,,,, -util_joint_auto_tour_mode_ASC_shared_ride_3_,joint - auto tour mode ASC -- shared ride 3+,@(df.is_joint & df.i_tour_mode.isin(I_AUTO_MODES)),,,,,joint_auto_ASC_sr3p,joint_auto_ASC_sr3p,,,,,,,,,,,,,,, -util_joint_auto_tour_mode_ASC_walk,joint - auto tour mode ASC -- walk,@(df.is_joint & df.i_tour_mode.isin(I_AUTO_MODES)),,,,,,,joint_auto_ASC_walk,,,,,,,,,,,,,, -util_joint_auto_tour_mode_ASC_ride_hail,joint - auto tour mode ASC -- ride hail,@(df.is_joint & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,,,,,,,,,,,,,joint_auto_ASC_rh,joint_auto_ASC_rh,joint_auto_ASC_rh -util_joint_Walk_tour_mode_ASC_ride_hail,joint - Walk tour mode ASC -- ride hail,@(df.is_joint & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,joint_walk_ASC_rh,,,,,,,,,,,,,, +util_joint_auto_tour_mode_ASC_shared_ride_2,joint - auto tour mode ASC -- shared ride 2,@(df.is_joint & df.tour_mode_is_auto),,,joint_auto_ASC_sr2,joint_auto_ASC_sr2,,,,,,,,,,,,,,,,, +util_joint_auto_tour_mode_ASC_shared_ride_3_,joint - auto tour mode ASC -- shared ride 3+,@(df.is_joint & df.tour_mode_is_auto),,,,,joint_auto_ASC_sr3p,joint_auto_ASC_sr3p,,,,,,,,,,,,,,, +util_joint_auto_tour_mode_ASC_walk,joint - auto tour mode ASC -- walk,@(df.is_joint & df.tour_mode_is_auto),,,,,,,joint_auto_ASC_walk,,,,,,,,,,,,,, +util_joint_auto_tour_mode_ASC_ride_hail,joint - auto tour mode ASC -- ride hail,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,,,,,,,,,,,,,,,joint_auto_ASC_rh,joint_auto_ASC_rh,joint_auto_ASC_rh +util_joint_Walk_tour_mode_ASC_ride_hail,joint - Walk tour mode ASC -- ride hail,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,,,joint_walk_ASC_rh,,,,,,,,,,,,,, util_joint_Bike_tour_mode_ASC_walk,joint - Bike tour mode ASC -- walk,@df.is_joint & (df.i_tour_mode == I_BIKE_MODE),,,,,,,joint_bike_ASC_walk,,,,,,,,,,,,,, util_joint_Bike_tour_mode_ASC_ride_hail,joint - Bike tour mode ASC -- ride hail,@df.is_joint & (df.i_tour_mode == I_BIKE_MODE),,,,,,,,,,,,,,,,,,,joint_bike_ASC_rh,joint_bike_ASC_rh,joint_bike_ASC_rh util_joint_Walk_to_Transit_tour_mode_ASC_light_rail,joint - Walk to Transit tour mode ASC -- light rail,@(df.is_joint & df.tour_mode_is_walk_transit & ~df.walk_ferry_available),,,,,,,,,,joint_walk_transit_ASC_lightrail,,,,,,,,,,, @@ -394,9 +394,9 @@ util_joint_Ride_Hail_tour_mode_ASC_shared_ride_2,joint - Ride Hail tour mode ASC util_joint_Ride_Hail_tour_mode_ASC_shared_ride_3_plus,joint - Ride Hail tour mode ASC -- shared ride 3+,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,joint_ride_hail_ASC_sr3p,joint_ride_hail_ASC_sr3p,,,,,,,,,,,,,,, util_joint_Ride_Hail_tour_mode_ASC_walk,joint - Ride Hail tour mode ASC -- walk,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,,,joint_ride_hail_ASC_walk,,,,,,,,,,,,,, util_joint_Ride_Hail_tour_mode_ASC_walk_to_transit,joint - Ride Hail tour mode ASC -- walk to transit,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,,,,,joint_ride_hail_ASC_walk_transit,joint_ride_hail_ASC_walk_transit,joint_ride_hail_ASC_walk_transit,joint_ride_hail_ASC_walk_transit,joint_ride_hail_ASC_walk_transit,,,,,,,, -util_joint_Ride_Hail_tour_mode_ASC_ride_hail_taxi,joint - Ride Hail tour mode ASC -- ride hail,@(df.is_joint & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,,,,,,,,,,,,,joint_ride_hail_ASC_taxi,, -util_joint_Ride_Hail_tour_mode_ASC_ride_hail_single,joint - Ride Hail tour mode ASC -- ride hail,@(df.is_joint & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,,,,,,,,,,,,,,joint_ride_hail_ASC_tnc_single, -util_joint_Ride_Hail_tour_mode_ASC_ride_hail_shared,joint - Ride Hail tour mode ASC -- ride hail,@(df.is_joint & df.i_tour_mode.isin(I_RIDE_HAIL_MODES)),,,,,,,,,,,,,,,,,,,,,joint_ride_hail_ASC_tnc_shared +util_joint_Ride_Hail_tour_mode_ASC_ride_hail_taxi,joint - Ride Hail tour mode ASC -- ride hail,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,,,,,,,,,,,,,,,joint_ride_hail_ASC_taxi,, +util_joint_Ride_Hail_tour_mode_ASC_ride_hail_single,joint - Ride Hail tour mode ASC -- ride hail,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,,,,,,,,,,,,,,,,joint_ride_hail_ASC_tnc_single, +util_joint_Ride_Hail_tour_mode_ASC_ride_hail_shared,joint - Ride Hail tour mode ASC -- ride hail,@(df.is_joint & df.tour_mode_is_ride_hail),,,,,,,,,,,,,,,,,,,,,joint_ride_hail_ASC_tnc_shared #,#,,,,,,,,,,,,,,,,,,,,,, util_Walk_not_available_for_long_distances,Walk not available for long distances,@df.tour_mode_is_walk & (od_skims['DISTWALK'] > 3),,,,,,,-999,,,,,,,,,,,,,, util_Bike_not_available_for_long_distances,Bike not available for long distances,@df.tour_mode_is_walk & (od_skims['DISTBIKE'] > 8),,,,,,,,-999,,,,,,,,,,,,, diff --git a/activitysim/examples/example_mtc/configs/trip_mode_choice_annotate_trips_preprocessor.csv b/activitysim/examples/example_mtc/configs/trip_mode_choice_annotate_trips_preprocessor.csv index b0b82a22b..edab18258 100644 --- a/activitysim/examples/example_mtc/configs/trip_mode_choice_annotate_trips_preprocessor.csv +++ b/activitysim/examples/example_mtc/configs/trip_mode_choice_annotate_trips_preprocessor.csv @@ -12,6 +12,8 @@ Description,Target,Expression #,, ,i_tour_mode,df.tour_mode.map(I_MODE_MAP) ,tour_mode_is_SOV,i_tour_mode.isin(I_SOV_MODES) +,tour_mode_is_SR2,i_tour_mode.isin(I_SR2_MODES) +,tour_mode_is_SR3P,i_tour_mode.isin(I_SR3P_MODES) ,tour_mode_is_auto,i_tour_mode.isin(I_AUTO_MODES) ,tour_mode_is_walk,i_tour_mode == I_WALK_MODE ,tour_mode_is_bike,i_tour_mode == I_BIKE_MODE diff --git a/activitysim/examples/example_mtc/configs_chunktrain/logging.yaml b/activitysim/examples/example_mtc/configs_chunktrain/logging.yaml new file mode 100644 index 000000000..f23d63fb1 --- /dev/null +++ b/activitysim/examples/example_mtc/configs_chunktrain/logging.yaml @@ -0,0 +1,70 @@ +# Config for logging +# ------------------ +# See http://docs.python.org/2.7/library/logging.config.html#configuration-dictionary-schema + +logging: + version: 1 + disable_existing_loggers: true + + + # Configuring the default (root) logger is highly recommended + root: + level: DEBUG + handlers: [console, logfile] + + loggers: + + activitysim: + level: DEBUG + handlers: [console, logfile] + propagate: false + + orca: + level: WARNING + handlers: [console, logfile] + propagate: false + + filelock: + level: WARN + handlers: [console, logfile] + propagate: false + + sharrow: + level: INFO + handlers: [console, logfile] + propagate: false + + handlers: + + logfile: + class: logging.FileHandler + filename: !!python/object/apply:activitysim.core.config.log_file_path ['activitysim.log'] + mode: w + formatter: fileFormatter + level: NOTSET + + console: + class: logging.StreamHandler + stream: ext://sys.stdout + formatter: elapsedFormatter + level: !!python/object/apply:activitysim.core.mp_tasks.if_sub_task [WARNING, NOTSET] + + formatters: + + simpleFormatter: + class: logging.Formatter + #format: '%(processName)-10s %(levelname)s - %(name)s - %(message)s' + format: !!python/object/apply:activitysim.core.mp_tasks.if_sub_task [ + '%(processName)-10s %(levelname)s - %(name)s - %(message)s', + '%(levelname)s - %(name)s - %(message)s'] + datefmt: '%d/%m/%Y %H:%M:%S' + + fileFormatter: + class: logging.Formatter + format: '%(asctime)s - %(levelname)s - %(name)s - %(message)s' + datefmt: '%d/%m/%Y %H:%M:%S' + + elapsedFormatter: + (): activitysim.core.tracing.ElapsedTimeFormatter + format: '[{elapsedTime}] {levelname:s}: {message:s}' + style: '{' diff --git a/activitysim/examples/example_mtc/configs_chunktrain/settings.yaml b/activitysim/examples/example_mtc/configs_chunktrain/settings.yaml new file mode 100644 index 000000000..ecd18ec9e --- /dev/null +++ b/activitysim/examples/example_mtc/configs_chunktrain/settings.yaml @@ -0,0 +1,85 @@ + +inherit_settings: True + +# raise error if any sub-process fails without waiting for others to complete +fail_fast: True + +chunk_training_mode: training +multiprocess: True +strict: False +use_shadow_pricing: False + +households_sample_size: 4000 +chunk_size: 40_000_000_000 +num_processes: 8 + +# - ------------------------- + +# not recommended or supported for multiprocessing +want_dest_choice_sample_tables: False + + +# - tracing +# trace_hh_id: +# trace_od: + +# to resume after last successful checkpoint, specify resume_after: _ +# resume_after: trip_purpose_and_destination + +models: + ### mp_initialize step + - initialize_landuse + - initialize_households + ### mp_accessibility step + - compute_accessibility + ### mp_households step + - school_location + - workplace_location + - auto_ownership_simulate + - free_parking + - cdap_simulate + - mandatory_tour_frequency + - mandatory_tour_scheduling + - joint_tour_frequency + - joint_tour_composition + - joint_tour_participation + - joint_tour_destination + - joint_tour_scheduling + - non_mandatory_tour_frequency + - non_mandatory_tour_destination + - non_mandatory_tour_scheduling + - tour_mode_choice_simulate + - atwork_subtour_frequency + - atwork_subtour_destination + - atwork_subtour_scheduling + - atwork_subtour_mode_choice + - stop_frequency + - trip_purpose + - trip_destination + - trip_purpose_and_destination + - trip_scheduling + - trip_mode_choice + ### mp_summarize step + - write_data_dictionary + - write_trip_matrices + - write_tables + +multiprocess_steps: + - name: mp_initialize + begin: initialize_landuse + - name: mp_accessibility + begin: compute_accessibility + slice: + tables: + - accessibility + # don't slice any tables not explicitly listed above in slice.tables + except: True + - name: mp_households + begin: school_location + slice: + tables: + - households + - persons + - name: mp_summarize + begin: write_data_dictionary + diff --git a/activitysim/examples/example_mtc/configs_production/logging.yaml b/activitysim/examples/example_mtc/configs_production/logging.yaml new file mode 100644 index 000000000..8939607c8 --- /dev/null +++ b/activitysim/examples/example_mtc/configs_production/logging.yaml @@ -0,0 +1,71 @@ +# Config for logging +# ------------------ +# See http://docs.python.org/2.7/library/logging.config.html#configuration-dictionary-schema + +logging: + version: 1 + disable_existing_loggers: true + + + # Configuring the default (root) logger is highly recommended + root: + level: DEBUG + handlers: [console, logfile] + + loggers: + + activitysim: + level: DEBUG + handlers: [console, logfile] + propagate: false + + orca: + level: WARNING + handlers: [console, logfile] + propagate: false + + filelock: + level: WARN + handlers: [console, logfile] + propagate: false + + sharrow: + level: INFO + handlers: [console, logfile] + propagate: false + + handlers: + + logfile: + class: logging.FileHandler + filename: !!python/object/apply:activitysim.core.config.log_file_path ['activitysim.log'] + mode: w + formatter: fileFormatter + level: NOTSET + + console: + class: logging.StreamHandler + stream: ext://sys.stdout + formatter: elapsedFormatter + level: !!python/object/apply:activitysim.core.mp_tasks.if_sub_task [WARNING, NOTSET] + + formatters: + + simpleFormatter: + class: logging.Formatter + #format: '%(processName)-10s %(levelname)s - %(name)s - %(message)s' + format: !!python/object/apply:activitysim.core.mp_tasks.if_sub_task [ + '%(processName)-10s %(levelname)s - %(name)s - %(message)s', + '%(levelname)s - %(name)s - %(message)s'] + datefmt: '%d/%m/%Y %H:%M:%S' + + fileFormatter: + class: logging.Formatter + format: '%(asctime)s - %(levelname)s - %(name)s - %(message)s' + datefmt: '%d/%m/%Y %H:%M:%S' + + elapsedFormatter: + (): activitysim.core.tracing.ElapsedTimeFormatter + format: '[{elapsedTime}] {levelname:s}: {message:s}' + style: '{' + diff --git a/activitysim/examples/example_mtc/configs_production/settings.yaml b/activitysim/examples/example_mtc/configs_production/settings.yaml new file mode 100644 index 000000000..8b3f59cb5 --- /dev/null +++ b/activitysim/examples/example_mtc/configs_production/settings.yaml @@ -0,0 +1,85 @@ + +inherit_settings: True + +# raise error if any sub-process fails without waiting for others to complete +fail_fast: True + +chunk_training_mode: production +multiprocess: True +strict: False +use_shadow_pricing: False + +households_sample_size: 0 +chunk_size: 40_000_000_000 +num_processes: 8 + +# - ------------------------- + +# not recommended or supported for multiprocessing +want_dest_choice_sample_tables: False + + +# - tracing +# trace_hh_id: +# trace_od: + +# to resume after last successful checkpoint, specify resume_after: _ +# resume_after: trip_purpose_and_destination + +models: + ### mp_initialize step + - initialize_landuse + - initialize_households + ### mp_accessibility step + - compute_accessibility + ### mp_households step + - school_location + - workplace_location + - auto_ownership_simulate + - free_parking + - cdap_simulate + - mandatory_tour_frequency + - mandatory_tour_scheduling + - joint_tour_frequency + - joint_tour_composition + - joint_tour_participation + - joint_tour_destination + - joint_tour_scheduling + - non_mandatory_tour_frequency + - non_mandatory_tour_destination + - non_mandatory_tour_scheduling + - tour_mode_choice_simulate + - atwork_subtour_frequency + - atwork_subtour_destination + - atwork_subtour_scheduling + - atwork_subtour_mode_choice + - stop_frequency + - trip_purpose + - trip_destination + - trip_purpose_and_destination + - trip_scheduling + - trip_mode_choice + ### mp_summarize step + - write_data_dictionary + - write_trip_matrices + - write_tables + +multiprocess_steps: + - name: mp_initialize + begin: initialize_landuse + - name: mp_accessibility + begin: compute_accessibility + slice: + tables: + - accessibility + # don't slice any tables not explicitly listed above in slice.tables + except: True + - name: mp_households + begin: school_location + slice: + tables: + - households + - persons + - name: mp_summarize + begin: write_data_dictionary + diff --git a/activitysim/examples/example_mtc/configs_sh/settings.yaml b/activitysim/examples/example_mtc/configs_sh/settings.yaml new file mode 100644 index 000000000..9a5a2162c --- /dev/null +++ b/activitysim/examples/example_mtc/configs_sh/settings.yaml @@ -0,0 +1,3 @@ + +inherit_settings: True +sharrow: require diff --git a/activitysim/examples/example_mtc/configs_sh_compile/settings.yaml b/activitysim/examples/example_mtc/configs_sh_compile/settings.yaml new file mode 100644 index 000000000..598f84799 --- /dev/null +++ b/activitysim/examples/example_mtc/configs_sh_compile/settings.yaml @@ -0,0 +1,5 @@ + +inherit_settings: True +sharrow: test +chunk_training_mode: disabled +households_sample_size: 100 diff --git a/activitysim/examples/example_mtc/sh-exercise.sh b/activitysim/examples/example_mtc/sh-exercise.sh new file mode 100644 index 000000000..e0749c8ee --- /dev/null +++ b/activitysim/examples/example_mtc/sh-exercise.sh @@ -0,0 +1,8 @@ +asys create -e example_mtc_full -d . +cd example_mtc_full + +asys run -c configs_sh_compile -c configs -d data -o output + +asys run -c configs_sh -c configs_chunktrain -c configs -d data -o output + +asys run -c configs_sh -c configs_production -c configs -d data -o output \ No newline at end of file diff --git a/activitysim/examples/example_sandag/configs_3_zone/logging.yaml b/activitysim/examples/example_sandag/configs_3_zone/logging.yaml index 7742c3ece..93cf6cea9 100644 --- a/activitysim/examples/example_sandag/configs_3_zone/logging.yaml +++ b/activitysim/examples/example_sandag/configs_3_zone/logging.yaml @@ -36,7 +36,7 @@ logging: console: class: logging.StreamHandler stream: ext://sys.stdout - formatter: simpleFormatter + formatter: elapsedFormatter level: INFO formatters: @@ -52,3 +52,7 @@ logging: format: '%(asctime)s - %(levelname)s - %(name)s - %(message)s' datefmt: '%d/%m/%Y %H:%M:%S' + elapsedFormatter: + (): activitysim.core.tracing.ElapsedTimeFormatter + format: '[{elapsedTime}] {levelname:s}: {message:s}' + style: '{' diff --git a/activitysim/examples/example_sandag/configs_benchmarking/settings.yaml b/activitysim/examples/example_sandag/configs_benchmarking/settings.yaml new file mode 100644 index 000000000..c432ba369 --- /dev/null +++ b/activitysim/examples/example_sandag/configs_benchmarking/settings.yaml @@ -0,0 +1,146 @@ +inherit_settings: True + +# - tracing + +# trace household id; comment out or leave empty for no trace +# households with all tour types +trace_hh_id: + +# trace origin, destination in accessibility calculation; comment out or leave empty for no trace +trace_od: + +# input tables +input_table_list: + - tablename: households + filename: households.csv + index_col: household_id + rename_columns: + HHID: household_id + PERSONS: hhsize + workers: num_workers + VEHICL: auto_ownership + MAZ: home_zone_id + keep_columns: + - home_zone_id + - income + - hhsize + - HHT + - auto_ownership + - num_workers + - tablename: persons + filename: persons.csv + index_col: person_id + rename_columns: + PERID: person_id + keep_columns: + - household_id + - age + - PNUM + - sex + - pemploy + - pstudent + - ptype + - tablename: land_use + filename: land_use.csv + index_col: zone_id + rename_columns: + MAZ: zone_id + COUNTY: county_id + keep_columns: + - TAZ + - DISTRICT + - SD + - county_id + - TOTHH + - TOTPOP + - TOTACRE + - RESACRE + - CIACRE + - TOTEMP + - AGE0519 + - RETEMPN + - FPSEMPN + - HEREMPN + - OTHEMPN + - AGREMPN + - MWTEMPN + - PRKCST + - OPRKCST + - area_type + - HSENROLL + - COLLFTE + - COLLPTE + - TOPOLOGY + - TERMINAL + - access_dist_transit + - tablename: accessibility + filename: cached_accessibility.csv.gz + index_col: zone_id + keep_columns: + - auPkRetail + - auPkTotal + - auOpRetail + - auOpTotal + - trPkRetail + - trPkTotal + - trOpRetail + - trOpTotal + - nmRetail + - nmTotal + +output_tables: + h5_store: False + action: include + prefix: final_ + sort: True + tables: + - checkpoints + - accessibility + - land_use + - households + - persons + - tours + - trips + +models: + - initialize_landuse + - initialize_households + # compute_accessibility # use cached table, otherwise overwhelms benchmark runtime + # --- STATIC cache prebuild steps + # single-process step to create attribute_combination list + - initialize_los + # multi-processable step to build STATIC cache + # (this step is a NOP if cache already exists and network_los.rebuild_tvpb_cache setting is False) + - initialize_tvpb + # --- + - school_location + - workplace_location + - auto_ownership_simulate + - free_parking + - cdap_simulate + - mandatory_tour_frequency + - mandatory_tour_scheduling + - joint_tour_frequency + - joint_tour_composition + - joint_tour_participation + - joint_tour_destination + - joint_tour_scheduling + - non_mandatory_tour_frequency + - non_mandatory_tour_destination + - non_mandatory_tour_scheduling + - tour_mode_choice_simulate + - atwork_subtour_frequency + - atwork_subtour_destination + - atwork_subtour_scheduling + - atwork_subtour_mode_choice + - stop_frequency + - trip_purpose + - trip_destination + - trip_purpose_and_destination + - trip_scheduling + - trip_mode_choice + - write_data_dictionary + - track_skim_usage + - write_trip_matrices + - write_tables + diff --git a/activitysim/examples/example_sandag/data_3/cached_accessibility.csv.gz b/activitysim/examples/example_sandag/data_3/cached_accessibility.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..f5bafec20f9a0c2d92698de28bee5cfb85562735 GIT binary patch literal 1663439 zcmV(pK=8jGiwFqp%sgQL17l%hXk}zyVPj)ub8~58X>4h9c`jpfb^xrs%MK(;6FZZL&{SXJvYLxZ7jfwr&64fBX0U z{D1zJ|Lf1c{m=jH|Nh_q^Z)p_|MLI%kN?Mi`ycXul~XR`oFX%|7w5UfA;BpjP}g^v~P|-=bvxAedpinjOXMN zXRiLWG1qhU*?s-_{(1UYXMgK?_w&v7&;Dn<-}mlw=JTv~y!p&}+tb?WV~_Q<&1dox zb3C)RXRYpM`g&G-*ZB78?a%X1KlyY!edKHT_@9wKdiMC*+^|Px_WO<2zh~#qKGr_6e|wK?Nz1ObKl7igp?%NTvyHQ!ahE>W z_SNRhgJq+izFqq0`SdfsvwGh?9vjv2jV)W!4oiRXQ!T&yWPeW2!uA^Z@O*cB^586N z<~On{ZyQg`r|RA059c$pH+eE%V7@YI&!0A)bpGl8wDs&~JUu@?dAa$cx4-P-msdNo zfp_&YD|_FwS6wM^2E6d_twYdgM%P#LX zt9|oVAGbbhdY<#0@jY2;K9Uvee8Kp>l_%xYwB7oXea~OcY064>{v^-#eEFr(drnPG zR`z7%Ti!hR*YDyNi{IbKk(r(C9Ut4eTMkFd>zsRitUWuEhyLc%=NPPfJlO?*c6lJO z%$%Pb)ZClTRlOrSn4{I_oSdrsF7H0ik{x__{OoG3+UBg~NbDS$Cu=|F$@b2CR~9yZ z$J?XZ=QrM!6P~}B6Y>4ixEd>0B&*GdPUr)Zb=SAc$amhok55-Z4Lx%Bjyc=h<=y-dLV2tYv5Oqd5n8hwta1 z&o9Q9y(6~s;eE9B=J@3hW*K=Q9vBEhDJN7bfBb$?_8@V4lFCzyc`oXXUH}5HHd*eoON?z}KPG=q*+m=H!&&h|flRalY zR;5~;=I-uXgfW}5)_}zt`XN|kdZOA9rpY~5SJ@!3cn*ZfxtHwVK=NDto znTcE6cl9{WJUfd$`Oz$U=NsRA|H&T4{En@IooD86WuYCT%x8Aq{PVpS?KlN&+{{0J zvEZ>(Z(G`ml^dKlyL0wloM&87b~OesJ2|xBE64r8_To9{x^p&>o`81PW!}JXB`-@ zMKF+e5P#2Sa$24d%btU>H@6c99UuDU0kVlB8_f>HF|U_HmTQ)^=RGXE-OhWBF}rKm z%6$)SE$`)h-^bgTqaAaQMdhmLN}e3j?A?duh+E||v1fUCF>Tq6!NRieXJZ8Oy5ng% z z(2tfo?ja5*->}#8PO{m#<~e{kZ%%bwRsx9Flid0^K&;`p&##FO*F8_8YRS$*~+KlKos#P8?z$|D?UZ0pbh}HZyi8zv5=+2%c$?hvTyeYE~X6P9^65BV5T_y8hny_}Mvyd0{INa%`Qx zJinO>olEj;H!M3D%aAh{(-)`dsll`7$r4P)yRonwEp93HG4_M+iO=36U!M4O42^vv zr<|jl{WuE`Xe@u8FMBpQmARog0+VAl5|(67v+ukWz9RN|W$*9O%@K0#K8{QGE*Xi( z5uak1_?#wYO*;g}156W4J2xapraZ<#8@tA4v)a6EYI>C3Hc z#2q-;%{|GsXKi^i2?yia8fi@@K3#uSRx@K@7goA|cilM8MD;QLx%s)=L?}eYi63ys z@nusI6>+>f!$mYg@=d>Li^&Xm^M7rNL z;@`;tvT+OBIGUBUw_d_B=?C)bN9%jPBNxSQU2%{d_-S3o;GjvE>iod?M}6DMr#%NYqs z@}56@jR9V~LvGk-Vlcd;Z@zkQd2^bzpg9v07lF&FyO2OFXFgYkXj;G7NxKrk#<*wE zIL^5D&F^f&-@JH2u!NhO;JE30Pi2y^53eUUlXaST|HTmn}=NDXpu;{eo`u35ta^S>Ugh-N71N&f4z-B(k} zxS>2V=M}@0v@C zSA-NXig(3J*dg~L9zN^MiZuwi!g_05VZrQm}X+q9`c6d zvFvW1inKcqpA;{r%h)%YI&kb8&*MqjTKY)>l{>o#0FsLu^XF|RAmW5$)pA{PnsXuY zg?IJbT`tPeF|uVzZF=oDaq`X*Flnzb84*IxW$gRRJ;waRN`Gb^8yj25 zN}R&x_+`18sO)o=pL2P76H9nwD6>U5fsM2*tB+q!1hH@v`B%P+Pk}vY6>6bT4?eJ8WX~2uXB(ZnGX*hTU?7DS~`9``;$YKD`GH}J&$uKav-de{2O} zdPNhJl4}824y|@BM%+)XXg(TC7w4Z1z>&mWA* zR>Dh;F(=80CgTMa3sJFoD3H9K97f+O9zCk$RpNLQI+D+SlE}#5g_6c{-;)&27T+A_BO)z{2IpXK@m_8m6RgPfG z9jzl1Bi~JCd6;DkH1HTnBsnn?JiO!=M?i>ly>R>ikxHR#iRbgOUju?cT1pkTIaHJ)PPiEyc+ZdcYY6@b3UV_Lc9gJl!5KB-006hqq=U^nZ z1qC70GN{_V(%6f}iVRNTy~!(MBFEpG_zDYdb|J4bFH0`sv_8PQK-0QEBW+IRKuDCQkE4sfeT)wpDbHpdC#DVH0N?on0dd+AW#-xL z;>{Uz&NsPmZYNN2vZ^>TGF}W)+yFZt3m9Ackhjm~uX>V!b8&QR!UNKmY$|)f|K&3j zcRnD=e67)FLW$&oxy}iws9zjmmIk~_)K82{StKSYhhtJe=!RGT-ngyYu-t`fS>~e| z%>LzX18UZ5my5@s~yZ=CW?N^4VUMJ*ooje>!L`O@5!r+DNU}& zyFJTjlmIgxl{%pbSKf^%X0{~pg8Q63cnYkDHC}-IIj-2c!NGrj#kW}*PFB@@qhKET z&X&!}-^EsF7Zd-ST&$RuR!&coH7QFZ465?uv%nMc2Y_p?o#dYIgF~WiNcfwCkBjpV z`W*uCY-Yl#6-xqMo>VQKhceLs>HsxL))I5heaoY)8<@{336eQ;oEOQ727OR=1-r-h z>pH*DD4^zvsGSN-B|6Miw2nGGSVUS$p2-uBTR=)Q!3+=nz^Xya-Afi3(>@K4vH>|i zg!no2F@BA2CU5HCYw^3F{rPmBp0Fh`$N3qXsvl5Y1WqE5ICWo%KsK>*o?n2cCl-w{ zCa7+r8ViR@aV^2nwcx-JgD`m%r2diibM+6AmwpW}xKnIg$Bid~&Qd`;J}y@WSe!%` z*_(tm4HzRYX@YA~MTptCOBZ;fTug=WBjXuWN6vmOfX8X55tEZ_E0H6?jLwJPkrZMO zpr3M3hr(J+LQa-7JrH>BP!g&_eG}3FzfiQ!QJXrk0iu(4PoC%60i&lSRsh`1+H;XQ zFwO#=|DBrkF9a6Brn8$5xK5$IFBp6RWQyzGdqeqzD90NsJePl-x?XFJ#9Q(7c?j_9 z%@NPi4~bPCiE=q-{;vJ?#K#ig?y!|Q7Amy~zG9qWd$w`z1MolunyZK*JtF^cYxy!y z@(Y}yYoOwpPw-&N7?$dZp8*~JK)7>2^-cq`5hES40>LOIA( z@eFt2EQ*3oqQ_i|59rY}Eru!9oZ4Bwv{#fHvzNc0^;I2{LzHD6Zfv|bZ!cDe+>4y= zF@go{A=uB$0^Ja!1Z~Ym<+#P@3aK>+6y&13Bvy}Ah~dd!%Ih$u!3bls9?D+d@5l&l z6G-zC31GzNsNZPCtXB&nlNjVa4+`q_fE{3J8a^)Tw7KtMWy$)7k#J1FfmxCCzJMZg zja;BLiHHp@4v%_>5??)2Vs4z*N5Cl%4R7QT5TRu9Tes=j=W!&4?iFWEl9G6|0U+Y_ zS|CMcs+Q{0oWj~j?n*Q=5!p$A?kg@L+%QsP#91tGx;oOlr0-0g^s7uEV(2vP{Je~zAs(JXo1B~2psyQh+B^;gnN!3P^sm92TY!dm+HIU1|G~f8L_^k7S z7;qLu8x6(;F%qC~N`N;mp(Tg1xj`@ubP`zV*k($V&0_;Jn*fcs6PIXtEy+i=9% zkSgM6vvVF2v5@Ey{=NVxiEHwmme3QVzuEFZpx=K5yCt;CGab1zajIV}`YT6w1f(36H^M2bD0hBgS#>Hw2RBg*eu~3iIQgu%qH{ z;H1{WXCwK8Yyd%rVm#ypva@S!)-urA*jtbf06=4c273SDy^F&`OgT9t@934*a*kfU zsaJTL`?Y~5viCsfkO(-{4}sSbAMx~o=wa33oY^I)DbQx}|4|PxBp^wM`yg#ShGN-l z)76hU`_DVwqxw>>8Ws|a5sm%1ta*6?cY|&{jpQ zW47|bz4%rW(gejhmb#l~CP_g;A`qY^KZGzUE+yZD^?CG6UDL5U0NVJiy!>i*`>~#y z;Gviv+?zal9{(-oT+YG=?*qqaS+7mmV!FOuMl4{F4-zjCtQx&P3B-Ao$ya7&9vxtV zS{E4_2QM$`#ph?6lY;>skqJZ78M+V?k4dCMeI0Jw&q`J)k#cwr34A1hLN<>amikn} zg1U#mmllZA!-S&zJvMSxFH@J08IX3er+`VMO~jS?8^Byr9nN z$#L;ngNmtXQ_^LU?fiFu0=@^-=aa+sW%(~3A_$w13iB5qW8mx$)xM7mm2;h)PrQ-W zz%LD|)_3(~w;(IP%AqFrRTveQM^+C*(c8MDbHzREf}n*|fyjw}1nXgDZG6w7YK9GA zfw|=a=yDe~GIt@yjsJ+u)5+|Ejb?p{yO*TGYgU*bZdYL8MafW*+6UQj(y&u2Z9Q z-=Ug~AMBV{ocM>TMuUlUS6lQ+oHrhnB8`Wdf)KW!bjvfH*BOiPEssK7$Bxy^SNt^- zx$n^O;Hgdg3Krnuo2mZ0`4+E%E|A*8QiZ1C2H7!ns=Z4eEwp{1) z>NqD>GY_6~ol{{FOnp*BB8Ec7q6O*fXd;a{FC_}hRzQ*O{|G0js87j%T(plxEbSM3 z87K%;-IHYE37h<`_-pum@L1OrJlYS0?UiIbnUPe>4sJZGfv+2Ez3f)r11QRDBwoeZ zj_L1@oTYWdRHubcexc7^e6MT@NNEz1n2LhNfL~sAddMePBn4#XvBfY}v6I{KeKWq& ze)m&3E6#2=>*wcP(*1I<7C9ZnfKdz2&ioFy?v7KdyND00f(nq51xxaRR=4aiw);S% z{0zsjH!Da>`+cOEW(|!1Le8ePy%VZD0DVA5S=7f7ikET^@KvKY5fozyask;vn;$Y+ zxl!4(H?OUcOXUFH#c%m1Id1U1!MsFGDleJ%>s!xT1QY_?d(??ZK>uQC^P&9PkcP8y zB+hb1ymgSa^QRlIzl)Y)@$lx97{A&n4srrpkXUPra5rFS!P6W^*SW|f6Qu$w#Pr|Q z-x%=Y{>I2ssbXaxNg`|p_=3X00E>qtBYt94G(wdbMgfVuZZWP0awfJYW-4mAT5N>ghX%Ot-DZS$Q6be15NLD#*0_fHGtGeiVc(lnOok7i$2X8 zILQVD{JLmEw)Lfkay+1KIW5bfxV51%rrrl}m{QxmjLm#2RHXs;oTbtb@mcnRi+#C@ zr`XyA$A@E9OWiVXq19s?mxiv=&I4O6=9YS@8*9A`Z}V0~3-Z8*AsCA-X>hPdVzxkD zf{v8R$Vyke-FybF6-gj0=>jGfbqR_~c$^{jNd~_PdY8&i)Sa*0*e8OSyY|Z@0)>7W zh(V>`kQuGbQ8_8@68tb+MV943%)OvqmU9hj*~Apa7a>-5rzoFtmE>nodMkkJg?J=q zm&^-B2Otcn_eXh${P@Kb!Q#m!`T*tPjvA)?kOBU9+gnFTk)uJJos{4&v=3o|+Rh`+ z3FQIKA;4u3mZ4YyLG)Y#`EbJ`M1#(?FH2#1c#mYMA6yE^X|hyxL=GO{;Sg!WMG~TO zS146HW}L8cDU`f)Q2dx@dz4GGIfB@4*)T&p$F%hZ(s;WBE~xZGJiCBuqN!%c{eCi6 zo8)|e=f<({=n=Vtlq9YaFs1wnG6|e~W)bSSu1$_!?2B~@0qS^Q_ zj{Ly4AR8skHnk<9fJ_3_kzjD$g-uK)bkkl z02DOnJ@^Z`tn|k`d^zS|5XF+WbL>8T|3=`uc$;+0$h6~io3>H8q=yP-hT!-IF@S`g zjK#E#6fhPiH?J35$Zlt+^Rj^7ID0r-I4BU-;B-N)%R9v$+La~x}dG0r<56C1N z{XwT6Ynl6WT2Xp4lK(A!*CA{or%+Ht_Mxk6+&L*v0j9)gu|jl26;towy<@*2q07|n z%}VE?KW*`RU`g;SKB}jmWUfVi4MTZhY4{8#A@Kp=1)Q2LxreNiAf51mS{JWn)8R3H z@cFJ~(Vt?~XB0((RIN#;&fyYrgKic#--%b}Mbq;Av2+tq;1i^U{<)0O^1{5%@IoHQ zH-OdrmR$_6wurf^I7L&KuRL$P8A^tf|8IeGR}C)l0*^_;Vt7xk zMRd~mIt>Wt6Z(7JzE20WFDAGX*DUD^7!G3YgLNtxW6&P~t1HVgX(9O?G~dBF7d~&& z^2N0{YJe0_Ru9&h16^OIHXGWMT4;^PYK%()>dewzJCVM6^5&X9238Hc@ZVGHV$r%Kgg6L$w z#RqwbsES)e+yDujjvhY4t4tHd;Ou||ES)f;3MOx!_ZQ$7^q3=SUhnsWzMBq9JC(p}?2D+ktKmb%tl^v=$zy}mtq5n$mu~tf$DJlBa+gA3;FE2)GgQLv^VS z&47#J$Az{gllTjIbOLIMX_yZvb3~FB`q^`UzeYzw_Jdor$u*z_<{7)p45+yzJ8|Zs7H_5U=Gd7M5>YH5%#2HWl0CPkp!;M*DK^Mz%I_8 zzcO!~YfbwSHy%x<9ajgW7k<*II@bUsZn1;gVmIIdL|xJoYx+cxaNCHH;D$a^!(OaZ zR-*BwZHM*e^bV60z)eH)7vE-OOt>VYr z9b1!Nx=4@$qoB7k=Mzh4zbF|n9Xb>Tz=WtRJWT5+nxE*~2q?I^BB1E2emO(9_l;Vk5W82wU(n^gSXW*H0Dl~)Ss^fci`Xv;A%xzLl2(JBv<*aIH% zm$K9fA?U2;;1K@8uOI#Ka%HU;%OCfhicZ@yt?Hl%Y5G~|FErT(Kn3WsFy zGU<`8VsZ?2#gxe!m-d1FrY+({>7)pcGEo57bLBAaxm(ajC}aa}Y9rU8a0i?=dwmA5 zZc?U8c2+H!ANd8vqujgpORT9UHXJyq`0tPl1bvjD4LI?nzuFHzmN>rxkHo;{rL>BC z^)s|JLwSTL`RR^`dR{8__(W@~!vvowk}vl&o{|Tspx<5?OD-RsiLr56DyZh59UwUa z)k)~Z%eF*)*!;x+hBmPqPyi(wv|ijT0x5gSAmY?Ls7`dczBDffuL?mZ2k_yh(F0BA z?<1?7rNi=Hvfk)c>SAg#wXJM(zAhMiSgL`1}`5CJU|$)4FYRgILv!m7Zx$6p|PIx zCv$=V$-E0ZP#>cmjPN)v$7uw~zMiNUnW zS|PU@|Iv6UR<>X~k)LMQ;q`!)VE}Xa@7mYD3|w5vtCCq2cUqv(5D}&aiSGmEz%mHv zaq`6U&f6lO&B48wv(tzSEDg5tYqQWiC)5L=LpNd<_Cg4Vt>kM*p>G&x(AZa) z$cU^7_mcfeji-@X@-PI)RS_99I6c8 z*bi+Bxu~T+K?lu`YGL?B|B|j4QnQUt(BbLEL7YJ2OblIu88qasvhrm52VSlDIH=+S zCyZ80Z*R*5S=`%(IeqJZcN&@DJJ#d1f=$H34;XIF18`m5gEejmF7%MVCK=QVuJifI zV8HEwM+2ZFvF%2laN%!BeMw}=!@!<0)}Semsw|mu(ZKTqvUg+Mtm4xrK$~20@MBz% zh)F(YtUB#4L^Z_=4#7NUxEW!Q-ip2-T%ycjo{@2UjhVBQg zVe9aWQY6}Z&~s}?v&@%DMt|%kp+}FCOwZdc0wl35xeCqVz354x#pt(SUn_e~572yYwQv7kOW-xmtc=0j#FM?HxsB zAiJ0*C|(FkDyJA5-!>?Dsm(6gAwVWL8LESu>VGr+CpP0P9Lk)SvfO{Dj=49TWD_q0 zP=qcnRY;1brw; z9~5Ii0kU+eVHk$*`c*W9`ghx9U1A!h+Ap~+=Fu2RppS%EOGP4cCe8zpQz`7w zVrmJI#6zO2F)VS&9gL?r{z~U+_%}x>udcBrBKdb(@U8P!x24F&sM>2M9 z^1Q1ruI_()K-8~Q#AA}sPR992T=rs>}?Pn=oVl(WsgYy}MHzF~SAEu9fd9)-5av&0aO(S50 zx(@9Vq))w2P`p2#!W;vm1_Cag4!GOT(7Quz29xlVW(PO$)%ly!_T9GatGhy6ZzWXC8Vq_p*f-2w^BG_{lhu$xi9h4%gq>cdcE3TX#^T zL`^+?X8H3%;9r}YK8opEK$2n{4jI6B?{WEwz}d;C!?I&`{i}O8CiVQdiC@FonxSBNhD=mmmr{!Y@6g z|Az~CfOumgKFhZVi<$d3DQkFZ#$s$_W-bi)qymnha|Aqa@^}KxnA53-%QDiN}v4r@5 zPz^;H+(0bG;Vvq(z>xr10OTUy?L=ENo3eH=kX)Erd6$UGU&2Y_5PDrRC?e8*F;-RE zc#3U7Yn>}cL<(9H@*miE}OQGMedhBM7VVQbFC=SbA*a_?LxYfOaUQZe(0;v z6Qx2bSy_qvzzzwLHpGd%Rdpgz=78QD4_j(2)XV6UR(z!B092a=pWy;SK;h|e@$N|9@KRqQoG@W(5XzyipU`>qD2pK6?y2hwFU6l^oy+r zN~hheN*w6!P&}&wJ69F{wT$|g9;Nr1Uk+gmCD$TrrwL6;S z(4y+uWc}KK?q!Z)MC*eH&P_&$1JVwp8Sqs3{=i}FQ?Bv{9Q7h}Ks-iU?buib<(z^X zS`-!)1v-|#2xi8+ZVCvt?Sd-OgHKCDyF@UR*9b44TiS)NzSQZgW;+UB=6CsGF0e565gUTyJ({Oz`M|Vp#%zzb^JpF zNw+@n{rCJVOr@fPg1LR4DZEh#WW@(|u_lXDRHag3xnZQzX+Y1_d;p0GO8^L$T?kBU z(O2oYfz({}z;~U;AbM4dQLF@_=yjw(mqG=p6|i__RWTHN%CRKof|Egaq44ypjT8Q=im zx*~AsQ|Q~5f)fRAI8{1$)pz3^e<}k=!Y4DE{r{FibGQ^RnN;GNam`XS|sAAByUKENU0}XddB4N)iSMI#m z=K~&5vqBAPm?eS*e2rF~1ej<#wC^syv;mPWfSn0iQtitC10Rnx>Njm81=+=S6|(`b zQ6Yfk;~{m2mJ@G#N^C&T8*Br9#1xrD;eHuxs}aa&imxv9VJlm6ET+NDZlk&%keM^G zSg^70!V48?GG+PWuS%N7s}7M4VM*CNl&4AI&33Jm3;RC?Fx3+EFunq@O%$Pqf;1RY z!R@K5;B>$bAB4BtjxYLffJ(q%K03a}v3O;(!Vtr&7H@!v2MSv)y>yN=Sn6^=)yG7> zsTxTx$S!H9M;U4gM(nf(0>uk^7uzes&IwY{1`fV?H0=Y5z98&TVo#1nq0=9d3)}Q6 z>1k+8eu-2qjs7SB#3&H~v3OJpkZn9l_uq}LwsFKZU%m7Dnur0!)2^bXi}cNpoVG2+ zarRfH6jhMpkc0w{s2}C*(I&sEMSWULUqJB&m$d8=`;i5xVr&!St4ScCL+=%%O8xaE zn}|s!*XBm-rkLs9xWJYpP8EGY$p>)>r8MBvumx|4tiHr%OW})B@&Mqbnc%>`l;GGT z`=O~0JSa8>4oyUWYF^QO4>bjvkcJ&J^a9CJAiiDPQ!W%bpo7+fN6e1qXJ&acilpDK zuu+^*KSQJCQ@k%&Cvy5`%?(i0GV6u{Rp}1W=16Nax!d*(lW+rdV>t~;8Awo49M937 zFjfVq^^@9UuK-j}5U8c}1;k9e5L9Kx@^pn?DOuzEMSlLKeASp}gDPGLkl(<%YYe9x zkV;pXGl6O*SavC4WIc;_dK4uBB|zhVE>AiZdQAW*L5*W7WwGxd!9w%+18I-dH~k3u ztIEX=a(J?=K~h1&q*f4z3>o<*)%&hqEO9%B0t`3Haq*vr7BKwNaMRT^h|V5c=PGl+ zj7>WzG&6CZ>cFDWQ`%VAJlT;*5E?4egxCpB3EiuC3&jV5djv-?GdTg9CeXg6gb+y* zjlzi{hHAgsuSp~fE_ip?LEK+!Moxrh6E@&eU2IaXS%~QRK%pyYT?&RSqgu#rZIDj!N zO#{>{u?p0Z8BuUoUkc3Jx4a67R?k&+nUAdr(guE_4XfhL|v6!9Mg-6v4Dt^n-ZEHSg=G z@Gn0B=Lyv0y|90;LhoD8cNB5NM+<9L(-GaHhv;*Q$#ZQ`=%CxIwF=Hb)0=%{!+V1o zt|agceq7n(f$4CtW56?1F<)7T4EeI|`w@WiJ!D+_hGFWNFcg6b$t*fEXe9UoD>u`t z=JcsLD*BhLmDoSYH{trGxFMKKB}@ebFc|B;N;WawT4U1{|rT93ebfjivd? zFAd1Pr1=_s_Ex5#yF|&M?im6EdPKQ%aK}B2s ztl#P-9YLHU8>iQGR6s**kp_wEeRGNgz8ouyiCNXL0g@xKe4J!qcdFu1_#`@`EOjje z)xK&EzV#kRC7&nGy8EFfg^uc+a?#MT10$h&uEZV*U%NDk()FNPZ`)oCMH|js zBTEK(@ut0~Zcy-<7DFH>%g1fdRhI-NydJWvHMKzM8V#(#thT+W**w)t=r%%5n>C40 zF)*{lz^ib$JG%zT?k6>9rY$|^QHT22ce)D`uh zQ3*_m3rcY0T!4cbq%2y1=gsATAKzeKR7^%f$gBo3C$eh_dq|m0<=Rm^#JfJ+Xt67=Bghnl=&`ZY{f_DJfpWm_}5gaOX9Nm^LFAM008rfGVN)CHQMLj}aogvTx5Q zq{FCO;mJ#<0}^JGABlC0weYQH*(EwlR?`V*5UXtsGdV;} z2E^O^S0C-|ata|7X}`1!We6MP9t z&jsu;RHUl4zX}QN_L~tk3m3wmvzPd-It|>E4oP~d-S2JQcJG5P_FlpmaP#ELA>6>S zsGK8rN?BbIj*Q~*dHbETZKY5+}r6yVK&?9e`9th7}J4X<(jyx^q zhUTck<1FbBp45PnQKClU*w!g#hwq{uNi+FU0vUEVF({-9dnwq?y{ap0ik^^vefCml z27Nvvl>;{~%f*v&{a=LKKSd~1!|{YMOzHbc@;94#pmjol*bGyw+=(s>gce#a)-0q3 z8U)Y}6FlqWo~t>l??_x|4n63^o2|Ni81SXK86Epg0&pkXc^wWpY$^Z^{6iXkWBrtZR2b_xn#h|H0{8HVT!sgwvfs$G&L|Tra zF8JH_DxV@6A+wU^UxDl+zW`O;(oIIhi%ClWl%uHKR6C?0KqXBZ%z0S>G#B1I(xZG~r6HJs9Odw$fOTL&ToqAtpiyy9;1(h+vnqnJ5CgGTvC zsH5aBHo%g7lkao8CWbnPOrxoSJ8db9BtcS842McPyjV>AC(bifApq=vP)u!)oN9a` zkRV68CFPm5F%WR?>MOEiLMr`iSTojzme+HX+1;%~QyyoiI5e{P}PxRr349Ez|tc@_7fa}=wfMk3L@f*vh1olCsL zISa2dDVc9K{(01S0w$&wS2EFH>-^6Q)42=ZH7dr!4180l;?@K>icCM?T^Q{2oDfba z8K0Ff;zq>Ff#RgCJ(Tued!*4Ytm#wUhTPV*3e=Nb=O`C7(rw>E5{K?Oq*g3azFT&_ za^FTWb}Cbl;^y3&S{g~s2FTax#j^zYu!J!G)XmlqQU0NZ0O6;297xHbUBYw~*^Ug8 zfNoBYU`24oxg)-)XQ3GM%GIkGr~~ zt%|vy^WVNtcI8_c6P(0KJ(CGPifYXYr?>rI+~q_ z$3c|N@#4mOg76BU(wn<1TfTo=B6f+?FV712N7eWQ5D5Z@a%%)(`9Z*IxgpluIp;a> z8#eK=004)gOHd+gZJu}MWx^+7{&V+3u=3hzg_Qascpg&1nE^zR4@EfVF*DTKoa3m! z!P!Q$`!2p%OX?_tip?4y-2m{mT_vLgzt{rXW3CD=<$hag{F|E?|B27-)C{m$sQ4Hz2Qn6*&o4vqW6KI7Q8QPWWYYI$nd9eru>h9c9=T}loL^;nl!+ED2%br+1qjZfaCmD{n6)Vo> zQ*=|U;V3q67{c`y<$%~72^&v_VehCzvs z3fOzKLD9i|OMa$b)tjo@!taJjNp+R_c|S^EmUIkVlnmZOmoj&})~-wv{jt;@Nmg5x z<9Q7EP@srK-lx&^ty#p+C73u517O_Le^#n_K3j#ksoT+74DYFc4>8iz-R z9@1-+NEb+?JtGjnAEnU5-ig#`NJGK%*2%;e2_{e#Ad@^L#k0srQT0XqTz z4BYe9w#9Z4Gc;58pMg9D0UufgnjU!8Ttmtgv|~)QTL>~Ozp9M>J=HE7uR-oM9E#)G z!GG=cIzHZKJ@FO#dJUo(&*yv$u*`OW4Qj&R9Aq$rx{n=!-Fhj%9`iUK&yMZ=zk-eV zGu-+j(@}`3T2q!lk1`GYrRGR~!)uqzv)of~u3RmY#7NyRel&`ZTH3vB2>FzO0cWiC z7AI{Vc#C)Sy%DESQ0P?&@^6yO$^7a|@q3DCet4v0B+Ra<~P z$@RmO+YiivBkZEF&URc-Ccpv<9-ydq^{Xgcj%Z-6>A8bSlxHK>ds;~yNVq}^3L<}X z4KuC)-Cw3~Nc06$=-4)4XEPJZF!Vr;R*GOBuq`AF(!%-#bY5&UQoe-l%`lxZFcl=L zla$|}zR@ZCvoWQ_lQ)P4CG2@c`jK&9q}1cYs;}ztKnRqo%&C#6Cn_O#-K!M8&~*HS ze<@{(Wirr<#UmtiO8lAv&+D|OPc4q4>^y2*T_HHBkdSS+6ZcNV?r?iN5#?B z@EK0iZuc?NB|JH{@2HbB3DJh_sPVuo9idpbEhympf`M}f$e=||_wr>D^OaA@I7Ygg7o9y$Bc@B1 z1frI%MiOf7$`0kFJk@Z;xEPWs<~AMCQ*_^$SFM&k;3%1uD|t!@M53Yxr{Bh^Jv za!?pmfC_APt`kWS(rnuiq&Bm$Vesfs^!YiZ%Mif)BccE&%o_-$c{$sOdQ}l?7*p;T z^>a0xEDXT{`*pUXIK&X}Ddo|K9HftKt%=fMF;@8>nbC-xPt})x4u;LwVIB^)*~fr5QgxKbdK*DKQd|Lkp|?%J6#2u31$tZcCnt0 z7eq-{9TSK@F*k#DFTaTWu=597ZLkVm67tyKn(}>Xe+&^Q{~WSmQ+0Z3$13@kWu}g)CBP`#QP@WaOFl#Sy(k@Ye-E|2;!+G zatC^;C&nO9;d{m}L3^Q4)9vnrs(^2LW(^KiYu?kRM*3ZQ6_6Oog$YJSF1S^U{_BqH~sk=s$2B?9{jnx_*gWF^bjMo6J;)0W_)L{yp_~f(&C1>4pXosoB@cDFGGio2pW9!q~0i4Ukmy z$*JxnxoubRfsICn*BR0*4YPuY1->hJGn+4+Z80^;%h8NEa?q9~&!}p4D59YbVT%v- z0@T{N8OzQ1E^6d(+UKnqODw*XbUIwwaTz<0O>5NNTCZviOq#q3^nygZs2x%J=lkEn z)#1&8wPwdkJ(#I{K<%W(-dJq@{?z(Vaj>54aLV@wba#O`8j{V)%@6t~o1##R#69PHay6>{b|o)iGG}Wa$+ zsHL}U$BOg-9C7V=6t^8$Dzmp$>uRe`+RkLfl7u0HMDt0}vG=N;b41~ML*+MbBICqQ zafG$tsjk{2G$OxC*i$XjTkLyw}H)^SEq*w}?^)Rd;u}u2P z9`zrYGfQ9FTNWOzSH^HKqwBZrw|YlQ>7Qv#{!?8x%&)gWbXC(Bk|Fz` z@b^hDwAKI;Wm!t7I7fsifhjqo#)T8GHCXKPcH6^#MXwXK;43sx2Bt>M>jYqx(*U{hkn;Xhx(voKxR%Bp^ z-7ZSPkD-?r_4|+#XQGE<)|U8C4(}p;O+nEvp_;^VC?DglF8Qbh7~UQzJKvRg+81${ zK)7_5P1Py1q+mLnN{g#U3kf^YG2M6V?+@|*Q+o*H9HPjurM&;Ms~RIZwbtzrb@S+)lyST_)ed~`)EZwl5KW3oSayS zZ9{Lr_;uHY6vc$nJ}9uE765FonE$al2_b{74Dz*Qwhe~AoZ^Bt=yjMWrXjvLst1<7 z#i=MeNT2Bd@B)H0sNr5OK#vZT90bfbGepH`(mNXfoxZ7_I1MB~%CrCWy~eZI#bkp` zyNDf(4l2B8@E8fB)bgmt!lb`C@U#hRyIdST3`S)FHk|pwo_;8FqXV@j@yL-VZ!e zGbnB$)P#8z*^oY_534EJF95@0>4zD1=85rc^2H`uK{f^*@%9wWDhs3N|9GfQjd9_= ztLX~@p>sk-V>pVH>dReuQ`naisyHEI>kFL#?tvUPFPs49(77FytBUi3=B_%I^G@j? z$>WAKjBQ>7m84zb3qu8>($2&35pWU;9X~W5sjUmrzi1MI16`^UlHAS&qO%@)t0SOp zSRfmzTb-u7@%CaMx9)7ijgUe~n-3I7;U5yV%`97ls=n3n^+m}H|^hjcUXf-QpuzvCe7yY^mXUsMAOh~PfUzH8MY zp`NT{3DPs(Hxn~jb#GGVh?_}hV(5V$2S_@JDMM3DaJM}a!`T@_^ma|PZ;^n$K~?=wuvBgi#1u3k^54lGy>CRqFyHD&49e-UE_$-qK{E+99O&Xoft*FIyX z{ZjvOrYJbY_uG533xMzj);ulC!;K`K_*6ff!b2QU(q3%-bIFkesu<7Q8OzpKtB<+T zPJF4%PCXJzv9lE1ZP{3+Hg7fQlJ_o!#gR^MrAH_QREJ#Hc#=Xbjn62pzQ4tx-nE9j8ja@uq(xKqKX79?4AT_|2innUYl6d^*HqC*^0Q~tJCH_F2{ z8w!h1Uwzc-$tH-z!wDJi0#KEZL)MDR2ai3wd>3D8h3FG7ZO4J}rUs4Mft=>EYwX`d z&Wkc69NX#&+lXS?7zriZCb;GZ`i*8wyHpMM@MvaX{%Up@8id1XLOA}av(i^!oDLR0 zY^%2hLkYFUI2#{BH*F3?yX!Bu?<~<0CM+x6OQJ|maSTB(8aYzN&Srd+C*X}w$}^O! z*xp?%LcnXsL+(qF+)jn%>y_ny(5qOEK?0LB+-H6ynGkIs!*LwnteGCzjMBCM-cxJQ zG~llN{JFwq3^xXmoJDy9nP#$5$NXthkMkU1f%6jnC`+W-AWPbJR1o_IO=ew=D_Zp+ ztz`HX;3y#h*+Hfp^7l!}DcvcWV?X7qQUX&GX_!|f;)OIyDhqaYZqIxdXu&E;MkOs1 z^L*%}fsTc&L5-532KO-*#wk}Y0yr=ze#4Yh^88WF9C4wdnzI4Z*+Uc#jE7%mm2&PG z>RKj=MLQJQu|$kPuW6{$#x0(YFp*T8zVu)6+G`*UA=1&EzT;-5)Ib?u-1aSl^9OK> z2`xkfNGKVvIf{(QZIlW^j7Yxo6_p0@lh1aCX~`1%iiSDpX{)Jg`f9X{nD%9+O*V&R zcix5dR?q_aW>7iGO78O8zYL=kDMRRrVL@B{Ce&q5!Ff-K*3z~?clKxRY-x~|1WcFS zptJ}f8PrsQqEFH5C7X;!H2l_Gy4nu#@Lo;?e5)$}#4;FNsG65>K1JYzvX;eU*jJ9I zpO;fts5pXTSiYikfaNmQ*bxF&is7iM-v>%ll#b<`9O~&5D526ImAkYGr0uqWAU$UH z$hmzJy_d!>3^b%TU}%U>0XSM`M)+2}MrE)=C$R#TyeUM^-i%bsX*>#OaY*mN5^Vq3^N93Mqx6*2gQ zLXWZ+n3~L8d`U0S$-pF6#&c?xtb$O0<&9K#_eazNr+{RwOtX#p6Q$LX;)3wql%|GG zgA1b_t~qA*h(;mL`?2=(YX`Nzuo^SBlW7UUHv|%;+pX<%a8}X_QufPF#W0(AbMd$b z{oMJkh*$u_jZwuC`h=Z%-7jN%e@aebu)7BaDi&h`9%W_at!7qqt~{ zBm^~cmIA}$p5sZ-0mcKoPN@NCwd@J|@3yOys-`N6WWt}4Y9bJr`&Ion=gwsPYB)U_ z?|+=&#ygNkew2Yl+2y0KLYvz2i^uF?K*ZXh2lfgN*BozK4uB?CB+6j~7>!v7dz2HW z(OD|F`PK{T46ybtUQqa`xP`XQyb`}#iC9W1ttRFYiV+0UqTh=2)@=c#cXpSe4e%~9 ztwYqO`r-i2z&9kXID>CEpMk13v9Nx1e5Ix9NGIZW&?rKCNC9sREhfxIYNuvh)#``x z`gB;)FigvhiQAwb0Ou|vzsTg&QVDTL-%fQ3(W2O-If)9hB*?9RX`*e%$sDiz^Ut71 zhTHaUQe)t%(s@JefdN3B^A6#HM%=ENP}}w&h$Vssea~;{vY610fjIkUA^ueRK~tFL zjG_E(lkpbhnr)giqHiJg z7Xs(=cj@xX7d;9znSfl?@Gf{FFgoFSIEfuJh-p2a0|Jd^V0aIw@@Pb;yTcd4lRmv> zE#ThZ>URaMNV%aYFb1ACBb#Pq2OKg9IiC|Vl|aB2%Z=v(U=JuU1FBys#$LnoP;*Ds z<~SPn+CL_{cPj^0246f9Fxt6{6e^?)+f|orSeQeC#HtmV;wB?X^KFjkR)^DXCGK)C ziU_e?5#HksJI2@7iD1BH&4Y<>oZV7+f2Lvv8w*>>!!a*8BqBoY>Gm6>AUdw15 zM)9=mOf~m@Jna4(K_d}^CbUyy(rO+w4H7jL8){+R_b{5Q2{a2~WBApWDrfz?Q~5b2 zqzI)FTtM7=V3+sWS(@zuv6+X*N5Df60j!AIb_pRBG&?-fs*UDJ#*qJ%cXL;6^6%k= z4n}fK=2RatH{{4$@UoR*1#dcY1ognm(Dw8g(L-SE*z>d1NcW~ z#dB}RzC%AzdSwm*jgN3qwym$4A=;uK^FLH(z=6n#uSXhH8F0uPq7sn!3*;Ss`cBk= z?f}UO6>u<6OGO}Hk0oW>onBj#!R!zj21H;RAkau2N}}p-J!&=Us$V6ek*V9TeK=SB zIHP{hm59ptbY$5iGgEI8U4!=*wb$&ML6;2&MfK%UVWejHQV-deB+oH&U?^N=v+#}9 zfa3(Z%FG=Jf#p~~r2RN0C{iMeIx$5o9+45a*ihcPhwV5-i$IrWIx0muwm^w%#`ifV zK*Px4!T{9br&usj{<*M>`{WC$A7BBDK2~9kU@HhtKd8!;YB2*FlslFsaTEi zc3_B=5Tl9Dlt$X6k7K1o#oAYsV3z|(=|vVNL4xiE12yAahuFzUPjSRwEE~rJ9h>Ut zt06Zu&Jj&xPGztRNt@ptctuf~rj`eJRnZ$Hn(4?g@~VC#DjEpkV1?uX&<}zgMq*)X zbuG|WV{(}S&E27lc@Waz>iNZw4^Z!;{d~)I(=*kbbtAV=StDzFNvK11UNR7pmQVz zPXC_x8rIcq=W>?cw@MOK(l8?}U{I!$eXS6k$8FpkVPRdQA0@wxtKUMsRy?kGavpK52O z(08!A+dyLw?@_;PASNz_zYoPVYXhY9$iwaMX2&mKa%D$?8o~sr}?n*PUB)&fAcB?WQ%RzKt1ndu_y`pD;ff20p}{?yD#6EO z2xA>bgY{me9w`(`;4_uwqi_u&5aBRXENnMIv>$maeyBdv71pIaw-;rb7X$B_u1^Sx zoo;GM>Nv`=3fn+=smp_Y|6}J>T#}*V6drjqnp{VM&9jtA%!g2q?w@`oh0&F;^O%Kd&9mm2w68v2r<6F$0M{*!IDx<*dUVTLq-m18hUoY!45a~GI z;__%o(f!E96~!Z1q>+cTL<$PI7mUZHh!G(wI+l!ag~37AuazCcH2%n%+l zhu#U)ejJhF&^<^0(3p2hyc`*xO+Ajj9ketv9fDD)bYOtSz~&oNy1~a1zXJ`RI<8RT z{vzmA+;3F8$X%Rx$iaj{fJd*~gKlL={q*4dK$)6= zxfu6zoMh4}0;cT_@-N~}<#W2JKQrLInmonacW4gpWslmSNp}Xvt(W4(5gx(4375T` zY{|Mv51POr_e(5b+vGNOU!P;1(RmtnB9=%sG0XRBo9L?)eX%@{B=GYkYDrfjGlZec ziLWw>8O!!)+lmN**o6{8rP56i3osFV(=9e25p;TWtLO13b%E96;8vEvfC|Q#;A9ys zO(l#NC{N6|6!B94?i%uphR0p|F1{>kgNiPkQ)*_5;VJtVU=0WdzL$|e(f3o% z@iQ^5DIcw5!p3(={?oo#Mm-)$88*Z8@mUu#m0a;C$3R@Q`| z9+Vx@a`|ybFi z?)_7&VHydhQnt46zQ*cQonVC{y+kU5;<&23H3aTcr@Db`*RaW~+Nt-$ny$4y$(a}i z&~68-A~OcJ^e72)cm6GHK_}7Ec)2RVP@|oIh&IG>VckKJ4nWP&N~}D1N!1ALGvImE zkJ>0tVc3y01=1}Gs|o$9se@32IuLaK${rVLT>#ul$@rc-oO>qWC{;zj5QCDsr20|P zKf{6??tCd6b{B8XDtU~lDcH8E$D?qI^0yI&9FCWM7aGFebq7*3^+ME@^yb4feRW!OiauN zRsdU{cwW8G;?uw>QB}greb;V+v5}ji09G(NN1o9-D8bNuar^z@$=S5{-HHutUZTl% zN(7i8K5&ca5GhwibK$pbf5OA9M%h~r~g3z4JASw#b(FAhP z_I`c?VroiL-YQIWI4*DH7_?Q|lZ^DCzOW;1$j}aaXlWTj+g?C)<13wK2JfP`58btR z9K=TqA*12=)I=d-E|jH!9#0)V8&w#%$RoF!H=rOK)X%5XJW2TFzrvQOweP1`Jw)?1 zFU2sMJ)j{DYX>!L%b2tqM$P=SC|l+qEcfM$p?wN7>FjdB0;jjS zO^>;rjpv{FtvtEkT~$4{JGF#J@kO84#D_^S$P6Qm7{5`eKH)kap{9S3D;;a}JA#FvjiI1~lAL$IGQ_EA;l&xq(I(q~gfqBb zzn!=7y1nY}HMGO}sDsKBc~9B5yk^5$nJ_t;!(cv#NieM*LhZY&X_Aw{WHyOHR@|vE zP0LL*+{uXBMXr75+pYpKMS0{(i7V|80@(mlg5;La#w%#{2q129s%B8QRO%?V{A_s(IGEb@|QP* zJQfFg6c{MHhHMRffM6HS2Lu?lCIZGb1<*Fan4vKo?^3E>)N7oUk?58)zSVO7!36eb4o*t?cN}P;Sz4sOPBqdAGgE*H{o8M46XcM$f$r~HTW=8_N8s2%Xf)JBx)^>zIBLkl zbcDR4vy=7=TZY*(O4macLv$5Y_-=Co3H)P}4yWk1^XI-4GJtJ-s&fDV!yQ37ld-Dmt20EW0q1a?p%llkkT7b&3^r*IK&* zOaOJwpP_k3LkUO>JCAup<`0VW7xVVhKp=_3$a+;BeLM(akn=bVk4PsuJwb;B-fHuv z9brKE>hjOw)jFg7bsxn*RLg1*b}_1Ucx3Fs!+hLzbZ=6EXEX zYHU3pD)XmEDC{BQEIy0NC53^s$#AYqiP4Zp!BQe0DV72cN@xRiuugTb53wOwP;usD zf7l3MB3nO-yYhI^=^7h9vRkq6mJuY~2fGB0nFsROiVp(d+I{Iq3)O0bEO}w^e3f{$ zKf%lv9j|ZHL{N;&OFQ8z61nBhu2L#=-3sd9!BDS~8Ys7u=?ds@9R}_Y&@iKjayzm| z?YHkm2$ow#E7tqoq6-;`syQ{75>ZYDXi!b2bwuyQ%nqIs-~Gpd6R-%DypfXhg0X3z z9ZCqJG67;uMvb;d;^9{sX2vgWuX)HrFOB~?RVK%;I4hOb<6UKP=lf84vpw!bY=c4rh4QoQ z&^Io}gdAHV8L+Y)I7OTX$9-4Ez0`f7l=R&}PW?{F&Tml20U)tf*}R1I$dlaF12RUIanzRlIq4_E z`9bzwm*J*_TvbxqsSP-%x`&S<9@RMS%m#GS$s^SxX9^@OzMQsCEp_@Xh=_oDe~zgf zCX<_AYtvuxMeWxHMzWtsAfwr6^YSKY$VwNftqpvCvQ{-;r!&o?Qd0ErAeZc3Gd1kW zK2C0UO4yvh3t2!3tM+S?e*=*y9omKg0om7bc23{F>v|iUKZUy-({X!k=*EUL0u@Cp(*~z=&$=wq9@o`4F{@W?MtAi7w3hL??sbzE{f! z>VouM(tc1AVhFKS$A$;{b4^aSaRM?`QC0V?KM7%hl@tERojP_<6CqU#(_<^KT$ob_ z>v2*jffft(0Ubt4HyV1~b+kfte0kl#Yq_X~xqF!pTGIIz3gqtI`6qNG0nv#eVylSw$0B0cO z)>nu)(VZgQJMR6m!HfI|LQM_aMpmae5B5EU4SN;Ircb3$xCa_3r}`k*A%fk0iURM8 zPj+j2Xt+ZEZh)9IK@VxcuclURm9R$@b#L-odQ8eCeiT<)za?WnONbfMT1Q8nG4OXI z{8hm+*fx0`j>XRaeS&{LSt*iL%~(FF%T;+=CM6PNKu6M$1w-KaKoO=O0dwTMiR4lD7xp3ci0F6r^@hK)0xXXw-&Zfatr`97WoYA7=!v0fNqRiL-axNqyzZfU@{SrnrfD&`L|SW2|aQf%l7)D=(l#Qf2pb3c!}c z{LFdP9^YvCTb%7&2xzUS!aE%l{hJL8X4dgmlNn?!=Ipe;FxmGbMatAf?M5S@zyh^L zyE*h^uf)HlB1%=OsBT*}w7(c49WzsCPNUeN^JhvZQ!8;L+AZdLTJg4cLzn1brV6wG ze6ORxEY6}5*3`fvji+D;VxOyg*Ig}GG?R1Z*hPm(P(G5VfZ$2>B@R^Z0bt#e>TRKe z7X}r>!Bppk_`KC!Te}8)5H~hEv1r6m;^Bp^Lnd~Z2n0yx)GpXEow2so6PO8X&y;FI z!?9yf=RSINf4g3TcR=za#?hi$p!VxLS*GW;RucedhTOJh{vve<%FHNxX0_;}fep$B z^4;uKQ^yD-dSQtbadSQnZwhq_`AqaFFd+j9)A*#o&1#N*6p14;gZE5*{U{{;V|^Cf zPI^kA(XOHzz75wLX`lCY6`^iU`TQ%{KRUfB!f%r0UC`sOE{$o!(6KOP%)`e9X+5G7 z<0~0Jr?AQ3LA&|!WgUQEbw*y~&@g{=;@g>smILtt2-`2VDK)0!tCbJJ6K zmxZ8;O6v&oikf*j>%V*I?D=`46&QK^%Ir2#-WcF-)OtD}v0#dOjNjS{;lfCNdDbs08d z;k}(AtGou7OwUh^8f(C504RM0-n*9LGkvY5|5=?B@C<%TtPA)8YFVXDUZcIGyNYhE zuI{O|Qf5*hqQ;--m@0WE7d$m+4N?VVPv&DL*ts3VlxjaZ^UI{zU$j7E?6C3GfDsV~ z>HCZwIrI-=39}j|o-GNIu7TX0-`e%62NDAf|8|pVKuVHY28@-ZuKa=F*7+&}iCyZL z9C)tgPn2P%y2nouaS_K*XBFtGfaQf$ri#vVnpg43lny>sbvogLd3<8!QT$JrTCBF# zen+N!Doy)z0CXlF6x6G9H5agQKeSVrQK1;}Eey&reU+Z72CSd#eibelI!+mdK0fLM zmfx~}Owo{LYhn&ZUQK+Yc$(8=AD|}1pIMIRHZ#Y$Y|K^t&2t*cFcAPhj*3Qp)=2WG zz`ltcPtD}E(Uh5KKsd$lQ+gAe1ure$&6LC=m%ik35OfBSp_El}iXcfa?HdXHF4P~D zE4cjl%vwFr14ufm2CbO4)2Yk=2e*4@XtZGQU``(qOcj3t(s)fsB-o{eRPwa(k+Mh8 zuIG_1vmXP*tqwT@#`t^U7`O{;0HA!eh<^-Kn_^f+%x3y1pz|sj2`a7CX!J-FF_L-I zp(b{R5c*qr(V5^J+K&aPRgXhhi*W$dHvp!Rp+G3TO*ILU>itIV@*PS6DfwFco}&^J zmFv1{TW?L6pAwb=sNwqm`k*r^bS)n?bJ^EH$D0u4+2NHqWU+12&KX3po>s2$^;Qn3$xW zHS({-RS^K(a0_5Ha8jve11!+;0#ba8(ffZmC8IjVG7t$+B68Rvh0bX|G!2R7CX73u zR3oO13mDP4tNH2o#)>j+eRKK>}K7GvG%7RJJneYNU3 zrH(QjPeD}qS<7`^1%X~IzCC4Spm=|!cViqwrJV_%Oh;||M(*hT)$BOW@~dVRBW#wW zo0my9U0(ibHyB84h(;TZqXwVSqlqqwVU=`v&;Ur5#1)?bBuy7^06e$qj8HNLC#6-2 zR_eR>Qa40TLq4$bZ*V3j`U`*$v)cSXSYJ=zKw{tc7s8XZJ~(Za50Sw@!+BnBJRISr zd{aL)Q5U#W#xS#gv}iz-;O!2a+s(E_w6Y15oxJn?;IPq(yhQz&a2izX&R0!xO(77H z+bQ-Eh+A&bbd+CVz%Xzb`Rp(p25um)&on~;TgiAx2{e#;ET)?c$<4-M?y^xal|mZf zP^zU-2>nBNV1u9?C41}*w70Ecp@e*wUt_%_$qq8Hsz;*?Mhd5#eTk9_WfHw{C`GUd zx**;hdFZYTQEFx=Z7!WFQxy{(yY`g2@3P-W)*wAq#EpbE$k+*D+EU46-N%{dMp>Z1 z{8vyp5URWOl60jX5qnZ2t_Z(CE4LZ@u?Gm|I=ZP2NiI5Nx3)^T3uwtaLeEQF>J!rl zrBVfCUZ@U!hI$-KKSW+%pU=^5bo@B{0Nxk@rXz@^fg{af7&6&Rm*qKHu^cRGaRIRq zf2v)DivYo5N$Dj_Wx^l|%;n<=)fCYB?3U5I5S*ZUPEWIz;~`cBH_lFI$*8<5<=lzz zR52pSM&1t8c{#QLB$J}wL+>czn8j`8`8uraF5R7I4ghB!n3qV5L^X?}h=55mu7}+F z+{t<*^ON2Zgf1-`e2(5^p!AxBLIM{{nH@C4*hbID;qB~Bh=sPOPg#ZLl!!Gsyg zT+!2Qi+q=W0~doxI9k7=gbBRSe{`<|`V2#Q%THAc z3piof?fL(+b|*-ZBuR2bFO<||`*Qz_!QU5)nhO9!80xNx%CvA-Qxz3q{yZgUF?X_Y z4ZicS!fE9gaLitl9(RewCOcXF*mQeT99g8Olx+T2SM?NJOE6c{7C%Fq403D!v^|>B+uMy>LGYqy@4u!U7p7mCG4q49(RHCUTc|gh-o%{ zJ(ZW;>)U3Aw)B3}yQSts1sDPDnNNrm%?JN`)fo`=u~-2sz)JrtkYJmF{0Vb)h4Y?D zXKRoh7-oR}UgW0Ws0^d-N-eL;;F>}WPsbhh-i7+TyMlzA1|6Z0|DHYr>hCaFYyiLS zdmT8-n$Iu4(2?Ls&-+F+I>o)!_C$JDftYMf^gMmB&**nP_2uYh5XPtl2I7$8<02D;-_fCd5z=53BQc{H~EVQj=Ry?A>oeiSN<3QCsuM`HVGK>u6~Ip@&e-b|txT zb7F~c%k@$K4de-+BD9U1ITr1e>(Es-W7uyyJ0@dLPi3)jUfZNdb~U(**A5b};sS}# z^A|WQuY^Bz08%xnXTP_#8`|#_^$|X05uDS|JexhgQ6+}*DU5t_JGTU5zrB`SO92df zK6{`@J=@KsWhQqa{1LR)Ro)9j7{k;)(_=2!7py=>FsxfN4^))j2hECosP=GKhZ^#+ zmiBEzoSjz>0~OtEh8r^&gZ1-##^*`_brqa@EPs@%-@!y^z3nactDrluckb{&QHPO< zfyeR=j`GYMxTfsfYeSGsd%4cF&|3S<@4LKQ2ZVwy0|AHQYaCfP>OC3)Gm zG}LQb!8V8D7r|{RXk&s1%z-dm9dSRe zxL5iF5p0}=-~HqY9-BNnfnjV;|4hlWe$PIB8mR}aQNN?C^2lw|AW+Z#m`oZS3)1Dq zAgb=;@+R>zfNbP2a8ff)sZo65BN z9rim2n(+#(|64EJB)CwqE0}IVLa((fpM1>wXtAKG=<$H^drg<2`GiVrr!xApE15Oe z-L;bZnp6X3goEGLX;b~MhfWNwdcVzGqIiC+$hH+B2nnwjRo#zrq|vhem15h#Xba?h z>q3r(oYqHq{`*_DYnpgE6__}BtJ8f#0hvG?FSX772n0)9btThyg?LY% zk{pk@o}}QTO)$a8lg8z3dOBbAm#7IHfzF2V89nm%iWIvMLZC!VSsF&_E8|C*?1d7o zsFx*!JF-Mpn6>VI$5Nmr1AEifQg@G`bBR(){XfZvGPt4Iy;Gn27G*^H^mphQX{C51 zSHkxAk{=l78DYFPSCq=8e8%*S$O*S}VT;Zvt2D>z-n{LtM17zksQp#$<|NmDk6zdJ zO1qiVV)j1cYNooaasNaCshh6+^RPIzxabFXsbqooi8l2#8rpsOp;&b`p)Xv9Hzqui=;>#x35#A;@P$nwv_5jmYH#xud_we0) zlOO76!d{H$AU>U*TXboU#9&WNz(!RJTiczRyUBjRt=gpvB(W0iZTyzrDU#CE$S~uZl?I@Xtf( zET%HQGN}~PRicc&=J@;6=hjYJQK})fea-gO(BvKHdzaNTD=HKamN9m(kq3)*oY{+8 z{T`tQes7|lbwTlLxJWA0Y#&P^KND;}#L{M>M~&O#AKoK%x>T72;%izncGrCV-^1V2 znsHNH=m+lo>*<<*qadtH{Y%}=^)1Kt8L`r$sWkH|v*A>V1pGeEb^=z;E!nfA!E z{fd_m)dOf$Ni2L782`xV_C0$y_eDczO`9w-slhq;C_0_n#XbFIFNInwH~AAH?zc2S ztIV3ry=XH5mrOSMcHE-!VrU4}bX_Wk^&EW~g_{FA8`)zfzeywAH5rE7U?FH+_+&(- zg2e-xr$3fy6J$HF@r*x6B3yz2&ff_p*1334j;SA~wS)xrNM(4wAU>KAqT2aavvT;q zllI6GPufu`i?$2OP3wlWE1Yga%_3Ip^|l!RvS)Oi8ly5~=qx^v`hJYhMt8|HmT+=B zs;5hJH1Bo9pUJ;?I7-v>Gf#s0du~q&^g_1q5AdFNt|O>XVA4r)I%k-U;LTtEx7$dCof88z}>nWY<>sr+3VqO1*f>o}V5t%bMhjWDfyqiKqCX)(M+1 z)AcggM4n`ySa(MO&8IB-3byHH^D^NML0l$I)YRg4mo{Ox6zT8x5&tchTDO#$o7NK9 z!!;wjLeaKo^bC*Y77L{bd^foiKR7Q+m99fNm!K7mEpo7!C7kaRxHW9;oROi##XKn@ zU57TD^fR?v{_JxI^?)d)fv(uOzYl|;`ucM{?@b%^`3PQqPdb$z1IC4T)L-M*dw z*#G03x}J|BX`1n-e~$5V2E5Hb`%du9Bcg?Zd4I-Y>E1&EPr&<3Z%UvS)fU~!HJUbv zG|8Nvu-lnBaM7HxaH?mNBgMRs!HzaePDPU4_rHVbn7TZ_@s-C{x|q>`6<^e@7Yi`?&qwIp6G=>b@7K!J$V(x3Yt&@Xf5CsRn{kOY^ zTY8yF2?>rrF8W?3BMBA@k)~tzR5{uW@;E+_3!1~+6~rwK-jO0O%TnJk$6ds8o3xczWQ%it)WZ=iZY72F zaSx68O$20Gm3cYJlNVd)^IaGdqC3TBj;M126s6K^=TKqa$?tVx4_*hDlE^sJvKE%i zsKok4pwaVR1-*txBOsihb@@}E?n7Kc`pBMl5VJKDmROtK{aD9vmVR;RU-{w%g~$B? zy+SY^I$4|<5d1Z!ljxrXtZe%@lXoxbw&mIzkTfW-6d5}sd&+r^K4dM8Z0@TCvs%Mo zN#BhxIEh}Tu2PKr<^j^6QoyfqE8Dw~67Wlxb0{GynYeLbTZ73Mfw^G1P_L6o65qC$ zX(_LMi}S*Lxe811M!W-?AvC_CxDd1n{sULDH4;LsNtjhGzt`uhBG0*~O`9&O{yFIj ziyr#ius)ahh`i2(J5|8ORL&f665$?7?d$A$n-IN?leZ#S-gngE4~?tNk~!m20pjH# zM<{FMJhMay?V@==Y&ecu1>FdgrHmlo)3;Rjv{Hzp_cLo%Lzr}Gk7yoHlqcdTyK)`3 z@J95Y14Mi}=}g#6_I+ZZ$>ud^L~O^uKT0NUq=t~mDWV# zike=f5>j1p(fOH1dCq+ZqbfCc4B=zprnukery#7hVE?=Ee*qO5Z?UUD>u3jzL zi84O_<=mA0ms)uG{_bwju4^%D^@bdCrT`e|={cWkvSzR&kI$!VDhkZ@7}WV0wbK#L zCbd9;2htfu4JL9^8l?Qvj($HL$LVILpmYJa{vN(_UsvqbMyaH%tf8biYokrF#Y$2= z+$iJvjwtd~GneZ#kY9n#wjw6?FB8wO$ryW|btA*bs;V3E#X!l@z7in)jvsraIVcfh zrk3o_dJwKe^!M-I#A(1wiZ8r7eSoiPhyOsz>*(!5?h**kOl$g#2zV{SeXnq8AB@|| z3taH(J1J5m<$DadeuflJwNc7LKg5YbEMUk~)H&@|SqC4ep$Wo`@b5jpKWEK;jU^au z$hoj3L3(q#huTgeu}$UUB;X8nOii0(Yug8a04qS$zv;VS3?SDtm0u=|RfF0}B0YTu z+HsNRYNvb+rXBxy?-Unvpcbx(Wx7q*7xNVjNfr&X)!zX-&?T9jw%sL0Up+@6{Fy$y zBk3d;cL5x?uoYjKfJr8y?3u=7C9W3a{-Ft&=D8ak8I38uRGDEQ`G-u9Zi^p507|*f zt7@6%<@gGd5cek(gpU`UG^Xs~o-Sy!>94p{sH9h);&L3fanas-1K#eQ}C zFqh=lS#W4w*YKiPquCXs@F%5BV)t7!bF%--=O#6Gn=3`bK1I*bO$9WruAS|vyMtl9 zpV5Mpz2cVLwl={#iwr}fVV}gO zOU{JFoaueXu6>POgC~0vS@AvkGZp2gRLhYyZ{mt0jpt3s)ewO~Nq-x#;v*XY98POe z?sCNcNCLLdDXI{)?fgPh_yHBi&YW zI^yIxDCo@5E^Qx)RJpRh({%!CN!f8Lk-8d*^udc3FjLK%)%QGKx(VadMX#wef>O%egvqJEX z4Y7|noV||#Hpysjq7wC>b-KzFXo#R|?`)9-RWW~*w?|4j;Rk0}`zs*DzRmbr#4!Y| zT;w6$3nY@dKn6OvkvrIaUq`4bF|f!z{)hHngU>LBz?2U6LllVIFpAWy^R;j-4)!0V z#LbW_ee!t5tnZhZTZZxJnH5HjUtfXWtn{;^90G4eBGzixi73`_--?hhKx?}tmv{Bw zG%XlCR?}c}7GkPFW7Cw1EdIqYv!^k!rwA50=FM!@v8wyGZ5 zkaa|3ka~9t#jRKs1N;Os6c8U9dw)_pnf^}H)gJhD3snn?IO6d)9N-<=@c5$PMktHc z^G+tg_14gPa+

o@<}7wVX@U7Cp30e0AYmImW)bKW+NM3sfoOGWceHZFbYuMXPTg zOMhh6^Ujl@q|hvU;E#NzG)10Eg{5T_yT6un_0+N^8bep^Clek)cS`oUr!ShX} z9X!%N6uny`)QUtK1omHYFYQ~Mwx1!z<-zb2V;xxWIdNFC`z)y{n)M;tiheW*UV zH-XDGXpQ{|y=X;Gz{HV|jy&`_=Or=Hm14alV%WM{JV;p%Bi7wt1#^c0=(zfAjNhYo zkUk~O9EGWmg_Ats@(b+wXPhAfg8Fzz$W{v2r<_E}0W!M?7Cedcl(bT9cTbT?d~El? z{}Njj$UKCO%yA+&?2vI6*RbAYg^N0?>NQ$dyxzj&|CUTg17xv*FCAoExNu}_Ce)b_O<%W4&5&cZl znxjU;(-e8Cj^N&B0ex@qilaI2TjEm(5%Z3VKZ^C*dRNlt7?5yM!G4%sfC^sSK^uAK z94Ry_MATlqPIZxbUy{(6KK{Def{?r zt4#)Rmkp{dtzcqjNHF@Z^j$E*`Aqhi85CiI<5P`T2(f2++rYBDvoXHXEG~cElWR}y z0gz$AeT{*+@RmhPkKn@}-WG$R@cQTL)14zF-qM}U#9NGy|6hB^s|pRfuSI-56Kbc{ zclo+Yhg}mN;yvlQ3A*zmTx*1uqO@YYOS~8K9EtFKN@C{`ix)@=_dR;KI1At;(YtX% zy`;4whueM-qs_k?YlM*RXdnBo(rmoJ+#qa_{9|s2UP2qY^9R$hnx9YpT@p+@GZs<_ z9i6aJFLN3!4R22Of;;v@M}q$P6VUy8_IBJI_35Y;b?o0^u}vtXOpG>VNOu=Y5YZ7Y zd*v5X`DNH_=NnKp|L->Mvf_@n!IU( zCid9+r|eb}1~v3Yy@h9f(WaH%8vEr*wcyi}UFomal1$%jVwNb$&EKjSM2Y?{(XXi3 z^o;i7>tir31HNK=SJ|=h)v(YKbgEE{_2(GCDPQ;W)yoW;+?$9{wX2$XQ!)N-`Pdxq z^*7;j9eZQY*So%mv>kGV^lro*GaNr{8|@J^V~oDHkSa;`pL*G#1GMZ8nqD&KUDJ zHrI8ea2Ov7B$K}p{=cKYJ&72XT28-X0vJ3xh#61Jm`K^k zT7S=?pA%KO(q+atPyfM{cpI7!!t2hV`ObL&V?2f{XJpt)d=ozdYGBxSBkj$bamMmI zmH>JGvJx!XVaS1ZLO%WN$;5j1$r&<)F>o^IyAwjlgT5M=+Sj%WWkr;2Iro3R{P*zP zZ|VUkUyW@>NM*=EykeS^9@7MSX8iZSiJ}{Cv2X7`2}*)O)iPRmtzC$i6!P2ACl6yz zJxO6XBPskZeC}Lwy`cTg-BYP7!UA{65lN&p{~mo(n`sJY7<)i4zs|^I$>|4 z3+VXc{$eJ$9&S1?c=yINul6@`y(vMm$BklY`xKyQBky{d!%%$h(MD>ZO%R?cKs-r~ z3f-f-bM3SXkG1Vc$^SilCo468ddL-Y^5N+)%y%5PUzElbzRZqr4{rWRZM9U@N8a#x z`S0iUh22_*<*Z|x)ZW%Op#*W%&0H9&pcxWq_;W`MS+7RD`W;&eJ^WiVH-m@YO8B1r z_%JLN=Nt6v;C|^p`C{)b54D`^Tl;9W2chTgq!2ju^IH)9Kp2dVyi+EIm*ArJO8N{ z;2styX|ubh=JxZEl>kl@V46Xb-F(#0#ALkMDXH$F^w1AUc}!W>!gzY=GekUQAg za>?Rrf6rb6an=aj#&7N1(@m*7@KH{zr%S4C8IT2L6875_GcdxkMf;tS}S&$7OU z3B1k$esv>Yhbv>XkR!FQddp;IBiM%|AZ%v2G2`A=5*k~<*&%Y-uD@sR{J||glZG5$ zcCk*>7Ps!29d56qY@iB_IWr;@7k%~pGjzA&=?sG!CfgJjzvo#yXUX5^=OLM{N?G%a zI%-;#cGWQ9HBEqS+U;{Uys_bx9{qRs8}XyWGFPTf!TP^wrnWwH`Gh(aiDt36rq$b} zH`x*ha9FgznxKAo5lA*>FJ8fD|E4xK2wpUI#JN9W{2@P z9uWl0cSQZ(=bWi)VT5(kM^r!@Xn~)t-_bV-I|zd`wqYmhdPl2vaCsMjJIx1w|9cJt zw|7kwJ%IZhjgU0Br2}A66V&Y4&V`f21qMdBKJ3(<-m}JL$*J^q+xP5)TakORxoG%p zM*%4Z(}sYxGy?uO!D{JsI=AbTogPEWrU!GLQX6k`yh4fauCg)iBhDhyIPOsP^XMQ- zH;=>=S}Qyl3C**`kY9(oPiW+#WE}eUx!3r+ycBCI!i zTy;0Z9njH?EAHV=*O&5ttWo1oxsIrdEgARvPUP*JM7ijeDLKxznMIF~px#(ofI(O* zyRVSa*ec(XmuN1VhI-W8@}78w(|NFyaNYM%ZnD^$*4?Rp7)c&ViJJ%bU#Zc10XV;> zct=0pzP;DZ&r?u|aV%d7=*LHNd#F{B&+WOot)YzmKt33}%3|mb=TIr~o0;b5|)Z^767S!~B#TOy2Xvdl;JZTab+J zep+#*BT$ts;Vg2I>6qrGi22I0WO(HI`uo$qXK%}$k%$0BV)cJBjz`0?TWvMS%6DJR zdGRKWUHTJm>v|9%?bPhV7gZm=oHqbJxXPh z(XfQiqH%sdX5r@_$Bahdz-Aa~&0jgIv@yPAEhP12K32L?TgE}H5*1HQAF^lfw? z6ifa)LBh2pHVzJ9QCw@}#ku@8fm593p%edcn1+*kR8n{M7F| zp4%^I2ZunOz;q@-shZ#KvBXeFLPjuKzkeG{Cw=vxdR7(tF?H#c6I4$nvt_n37l&u5 z5HsOVX}*1lD_Q_fa-!~a8%HhW8!)Jlkglx1uZRSi-g#oI-QZ_?q*NML2bc2M4ZL(b zQf9+od=d((zqQ_xDjfPL`3N>}KnG?V02A9sqvEL815@a{wwAq-zinp~MpvI+d2-1x zgVUMI^qZN6^Lm+LP38>CArEem6nhSv29X4vP)9vsLOJG0> zpxR9Tio-!%M||o3a(>U=lzOLJL!-JsaWS9Z&?d%&!!^AohL}P}_cgVetKvAatQC%m z$D5dm*?Q&y%4+{aD-5clu4UY|*1O4_!cdRm)JXESZq#c0uz5_YTF%Qb_P*yHS|Lz# z&MkhlDuLhY?Q(tNaL$KCX(>@r|DL_m)Zg$ZK3uq(iLVjy^pMq*em@dv(#+qkb2p>cA9D<{-xmlV zZ-$o7-SsNErovyTy%+BFFVmJVL~iJH`Lb(Xf|1DHN(-O6e%zhe4_h?pIJtg<;H*D=aMTS2Q6m<-{cSS-&7N%sXEW(`pl(!xrUdj!+PLQ109=L9w-W;0lKK z+dk_?LQs!2ihmme?#ycaJ^K^pCQK;y>U#{vhu`&$!ShVIjq{DnLGDa|x)|rk=X(T- zuFn%pDU{EK)|)6e`uDGB#*D`Nj#X-?M$1qZn0~9dACqdW)U$v-{aIG2Ot)nFeyS_> zbNp?QEs1ou`veU<(u`}E1%oph7)kwUj>rRwi-K-)KZ7t_| z```2(!%sd#6I^QH*9!kV(D&Tyhb6ec>d7}pzrwt59#Ll2xeMt)5|lt6kE>OQ>IkC2 zdOwk{Aqa=}{Ta79q=v3|jLVd8!og5macnyB>&{|^1{%HT;}xfa)!N*npg^6D72fE( zU=Lym%N8}9qnH^!6BGB)L95ZtFv{$$!>J)YF@5sf#6G#CavJPZRd-eAOMZ-%3*o)T; zA^HvZjxR#}?z_KHJX3S=g&e`~j16o3RtGpjuP>@e2Gd?X$~k%^Q))medFj_A#+8Qa zf=Vs)FGTP$sx9V^X$5Ky5l!f(GL|n{FER9e@kMxCdnyghfL&tW~(Q9^^fY@7llSL z6C@@0fM%lX4$kihowglhL^7rib%pLu0Fh$e?^Zuzuep~B(&lF}0iRMIQ*dMDY z2a$jAMwumc1sy;UK2e6%lK|sT+R#!^^d2K5P)gTE3;_JhI(QYNl9Z`Dyn=25hbys3WFBGYBXM&_@RT;UhJm z+3qT+{HmFb_~LFLm0n%i9{s zL))+!?PD#J$>X=nLgTAZlJ4=>o|Ya-HOiGOY-Cg`*qul*u=3H?o}p&AIhXGUfkeA8 zgY-z(vWAO;rbCbLb5`oFMIU}d+GC%A>LTlTS6t8fb7i1ruv>pwF8Ch})FJx2SZiH4 z@Is$Y1>^eHr5Nh9`imQkw2hNE5x?>As12wr{*_N$BgPu~ojE89N<5fET>iINHhydY zYoJS?)cPo;MJ$7T*SGyfJFrVo=-oKgj z{@!13V2{$on0@0tXM#JHC_Skd$NH1NN`mqfw#Ho*zT}!Zeq4@!M%!b;ZyfvvEBb(Q zB1yK#xgY?V6KO&d(Rc6JLRt6GI?7+XKXbt|G-V~7>l|=A_pE)CbuaB+d_c<}te5-w6vt{%&ug#43CA6(gLjU@6UpaneOrLNc z0-=jEZ~bmAxiU@%hlmIYjlCMpl)}^k)Oama%khjBwU3APLCgs%uDEG; zzdArMP&`^!S>`cJVKT;=18xy}TgAD4jGs4GSD7DGz3!P?aMZ=x`5yh8i_jqn$>43s z&`ZEL`4F82bq7|8y{#_iRP49F^Q%zCyXnQdhesr9dWXGnm6?ruxWD%V$U@fCeqU#> zbW#p!+ls^MIpK=lt^%pVe;=G2UsHPXyY=tMuUW|s9WadN(=MaRu(<(nKI4Ek6@HWO zoipeTUvw6lJ!+VPRcS(y*JUiHGp|F)RsB}ajV zUGvUCRi6`ivkslHqBpe#sc=hfB9X() zL4q^P*{>tky2>(UnD7yBb^*OOnRGHU=JD$jjsO1IKW?6@@A8b0@cxF)PY5XhJs(-@~{2A=$)^+E_;Vwr@7cE9}^PPu$<#$#}WRJ(F`%W^KN- z32?U?NH*CMeWx3Ul&;($|B86E4{bO^>6j4^{N3aj?QC*X51ae+hB9;_eI5~N)ySvD z#^3OL3iIC!a+{+<93K;BuP-T(r*Mk;l)$8Ba?5AOs{y$*e93_5XD$!`W%jRJS!RBA zp@@iZ+9u?L7!1bdj)u9-1x7`W;S8YUhHZaLPobso+znH~zLV4Wj=rZKBq92XoZh3} zLh%-$z)wtrYeMH%Y3dGwb8c4U0;oy!X(y$b=(1>*<(cls5!pyjnjCr3o+}RF3TE(d zbnHTeQsHIta5o8y4x_{EZNA@#%h#`=QVoo@A){k!h2kBQLQ6*ZBgGm%$D7WBGz0Fb%cMO^&8C zs&NOsWx0NP{9r+TZ(s;#`j5}9q$*)$S(ZBe-F)s|fCgGiV&9aEzmGT}eynwxrF|I) zn&nCknZUp4(jiY|n{1sy*nz(H(b%fEf@E3xMb1j1GO-fAZb^SFqj_K)gyI=9uCjZW`%0yl0ZqTnskoE{YgO zqw-$trM#;(uEfUW=$@(R#t$%5G;%<^X&sU8**jJ%!Cbd~>n65-_|whcNE3R#7D0Hu z%lFI*YTk#9qbKPzAmIghr_}zlo$d(WVmK+VFa%OzBurQm2a;23_G+sZ-tt_9} zlHtSIEklsS<@nr8M_i>xG4P&wbk>oPs0+8UMb4bVLdf}#e9!wfq;V!*9bUk~!5A!& zOG%TuyO;{P5IByPd0p1p=ElBgFlBuFE*GwwERW?J?oR}Ko+t57JOA1}yOMtM*ONJ; zA}F?xL1rSCc|(<9Ivqazambz1GA$Xi(ta(%6}XDJyaV%usVU=X^o=@W`EuQ7_cAYhx2@G z?WN$_r99SsNa}qn4YAf7L4?JR_^`K`oO3B9w}%A<8a=o;F+H1=Cy;{Fd}ipdQRUp( zLI_5-{^ae-US)_|*Fb~J@gZpGcdC3(AG0vXiv}l*?~L{|3mfvRgF3d0u7wR-1szUi zI!`HS;Es(Pxm8p+dpWs`LC?q=o43?nxYMY>-O=s$Z>{x4VXG&mhoXT9xX|w#!I*_co&4r@0NV_Nil=arXYKUo{G& z<$!Lk8NGnUDGhXSM)-4Omn9_m>xrEO(jbBqK_hlEQsksiaB5l>eKmp!H0FKbAmnYzc^wcZu;M-vDj*45(-rk>lSZDf@ z7jn(^H^{!`WHpJ-nPCt<(pFOgHTqfFs}H_$ zyYx;5o(?ez;1h~h3t{+Ej^9V=ckmpk#^BrBX-O*ezJBExMWlofLy5ly|3Q_+1JWAS zDCra1(y?(`J~PLDivD}~+Q$dHbQ6ORdCq$@*>E&I26+I(4>Uk_+r6R^`k@<$;7u)m zPjC+nbj=WdRO#eGP-B@I$R!uMBE?I5FNNuQ_`Yt#CqCGSwVx}Tp-QgqG1<(zoi&dj zr_NccQBVCv?=@=pty>}d*fY7{K{NDob;2i5e$6jYfiXfQr3M(-RM0#I5z|3My`5}^x zr=IbGs}N1?1kSxHuKmv zY#HRGwQjnAXy>hQ{zUxwnYR?-Gfn6>O?%doReU`8@KL#VpQ(FC^NKs{-fQ9rEUTLY zhoUv-(vkjGJ^BcCxXWF%jo0{!)d)G@pqOAD0)=Of_j87R9%h+M9)lxd@LXbDd$^6;Z+F5*8}KuK z7r9nZ!gOitAo2HvS!vy+C!^YxoX6q8IqDXo7`%tx`**aq9=4CZaOob-I0kNd*J`%S z^;I*b#=23(R7a`p1d~ABOyklAFPV7_t~D=(#qW|VUT_2+Tsm%Rk-Sw;XP3^r!6B6t zqkGv!!KQV#Q*Mh^BGUUF{Ua)^>4I0q_v|2OpJGOx7=w$*VShB+n^)o)GCFVBn-*(g zXAi>jWArP|e8aT%-j3+H35E?z@)|#*Z4w|jFkE%8#@v>H-!N#02e=%4D*(YC^6!YK zq*J>mju$~N0yk%a8W>}^Es_A4w;47!!bnOc_XGy@&H%mSz1a4q__n{*yX}Blh5=_@ zNj8URSkO9=zH6=mcXw&k9snvr2IhINVD1*Dv_dD>_ofb|SgB&8bX}}WRB!-clj+h= z7q#vlEz?)lPW9MRI}0J|E%M2Z*#uwmS|v@O9A&TZ{!NYX!W@uuE*Y82vJoz=R@f(z zmsz7EwhMR4QA-3?mJi6(`hlz_ZTB71KRv6{r!(_RUlWRKx_)JpfEWBddc(+dk5HK} zXG_PhrSE-{`04R-XZ+sG`)#0@HpC?&ZDBuC5>Bb$O8^egc@akK*=wh2Ix0qDqUSF& z^Qd)%$wd>(LODb23cKI=j(~lR>RMy7o{e#BrAkgPK@Z2UZ378290i^(S^c@EQD0h} z)oz66RWMOn>K$%TdXD77%QM{WHe&n;u_O2QrnZ+*aV&pJrk#yZWa%ccY?E2>Bm`AK zmj#_sOGX(qMDWGAT%Bvo8{Notcsydjac~g~>&m;^wP)Di5(cD*-J*^RWcR4M5FafG zt40`Re#5$B!22g|{!Nbs!}qsn1}c}YO<`N&PcQ3bquZ_mzy77F^FF_8$&VwjIp>s9 zI`y*Nz!~mR(&I|=)cr*Qy6wWH{N8KqUNaMBXBmmFJ%)Ep!%CQNcZ~~yVtYz&*mYy} z*62(#zio2jiJ96bQO`i}yK)op-T94swBOli8}j}y!`7p(lLFQ*vCYa+e8&%1zPk)S z0REFUdE>cQ+ir9k;0a#NxD{N1tgdEe5+v%lvd9>|A7jXcEnOY#e=BErh39yg3}~*8 z_#Wuq&Og*(jnD&$Um<^gNhI{ia1ZNTAv4vcAbo4IdK*JJG~?g9L^{so?=FkRNDsfH zO}?pVbxqd)6^P|ZSxkA{JPwb&_-EX-KN-!)d<903LE<>h4T_bI@(S8=4&nvml zfL)t(6D_c)`RI=Zdz16@lm{=`@GXvce+H#?w2`Qt7w7>i2Q9e!f?{<4t(+{%r3Iyl z-(z~8-IBNZ)_<3f@8qC32>Z{OKYEU%_@0A0s55v_)%tsL2K)Y{dI8{!^x$Iuy2@L- z-%K2PjG(^mUKTPMEpHwrDAhXgMn^}QNtNC23Q|O+$PcbW`@DSHEqbzI=DWE*b90^0 zCpwhT>Mcb!DAZ%Kf34Ny%uWHQKT950+v%dNZ~|NX+UL!f=BU$f664GH)|-E^+itz2 z&onuodU+Amcx@N5f+*tw<<~XXtN%>YRFLDBrE)xELSC#Axz?@pqTrbP(Tg&6cmRP=WlmEsg+51wZP(6=^rFD z1HKvZ=`KpTT$CbMi~dj$g%}vw2b*_BjK#t41|?qwR@~nWI-%oHhiUDlI{!+_VJ*E8 zvX+8GhfBb4rI_aO=-ETS??nNh*=*K3(&3(Hem;VoUbg7R0bv!$rzh3Fzmg4IKW0 zp63+mG756}UB(Og1V8Dy zP{x#G*OSF4mflA{)z*M~$ju0-TtkkUNX~ZCgg@XSIG*+(-6vi1_qf-HBi)tx+CSyv zc1(y6ZreQ`;vGUBEHPSKP0Op61Yq)?R*bgp;db~P8^Q4mvvTnp8q@`bLeOhtHr_^A z&by=WN9~Z1!}(+wd+E9qyTjqQMZlo5_odB&O~xu7h5H6}`8h~s)byLV zUvk@UTz8`7gO>a}hZ*8nIp5kBBYO8tnolKG-JM5wh-C60ll3BnT@Tm1fWO7?J^aug z>ZER}n{}JY+=0{?#01)Bs%3WM>mDhoyPhc@N92%LkQ%HpK;J9#id1xTFn8=@lA4r7 zN@?F#z3}}lUC(YH38@G&NKh;`jjn5-ZseEe>*V9zX(Irv+{&@)E%o-BWn$j7K3r_BQxm{0O|MkxC89C2FKcy`5gQij$@+HYOm zWBR=6oJszu`t|Sh?4%`IshRi0f>JKrz2sL(&0greUJ1FLhJ+c%p(F9zQDM#d*m>p+ zzsS$8zr$(Nk6^p{miFB_r^*IiO~yQF>HrTHjynFvLnN-IQYLlX@d_A*Frq_)Y3 zRqV6JOq(Fqf`XI}l0>zET3$mbc;04Rf<@Zp?^Z8!Y?%n~h<981j^%G;GYv1w>V|#R z-E_Q;;{$~7#!hp(<*vSe=cZaR${W6F0Pk%2yR02*;K^FB`BFc1TFiJ=85vCE9JP+K z8pwNsE|e^}hs0+87MtVPSf0h1Sy0R6Z_UhlP~)R;Hgz5ovyaBi_zDMZYHf6SapU+7 z{sm3qw^n`eY4B*RZ^w=Z+ zj{b|F%%(J#B~7niA+U#rXyVQv0#>zLG<$d%% zA=IgoVJ*~S*kEc0eu;0|bS`tQN{n(e5KLFJ<8j+3U0Nd0(Sg03X7}<8dY|t_YkCWb zlY$0tZ$!!W_;CZPo++Q?g8CUk?7f%{S6Dxzljf^5Q3#<+rESp}!EG(~HxpG6<-g+Z z?_=La$6^1QhV0Y1s?V_$Q?%Wqi1U}(8%l-jzJU)w-u(6#cxmiQ%cN6|?v^=8ee00= zJ_6&|BY*8yYl8~`bwhJiSSeAeg{p7-mQ>l{beCEmH}Uy<_NlZ^xQvamcReSadEO@kAYq^yNPMy`_n%#f*F&c_*udf zW47iCTb{Ytu+K=dI?-;^iv5|E?4~m7OLo1hR=zd;mOT8=MV(hz=~29`cA-xmV%K0v zF5;ga;#{T=ymqc!?YrNs3{F;m8Z78vPQ6!KOYyl6es?xVl*w!2x-pRULNfi@mCAMt z?Mp-0N;Z+Fr_7!wCDBY!PIcy<5IXK3(;(awKF;sw-9W^L0vg=pqSyEE6W7d;Ho~Lx zlQCEU-tO>>GoYfnW z;MP8vB>Q{z=Mx%kC4Z8|#){mhU_n6{AoCIn+&gWoHuLrzwF;eWK2sB-_^5A}#Oa5j z+s^pl8s)Gq_c5Wf;pZ|u(+(A)?rnYIPcapS`91!Zi%$c=d$Rtm_aQ+TKSs~$H`kSI zc6ua1e5VS20@mvWjy8)q<|@VG{*(g0|3txNPIb>22fIF%%B*(Yfn@-&_W-=jXBt@- zu3A8^+rmjn?0e2LY=p!Yq|dEiGtVAgNpOxC(UlCmRSN#g`1hDlv`W>G8chZ4{wpo5 z${RrSOAaM99mz3^t1IbfKygN!(NLHj%DbzX z^I@&dbq-d_z%Vw?B#VTpUmJA1Q*{0k;Yi>5PTNNSVm26j4FWlq)R0Igh+v7ax}`K)=U`gDQ7L3(Ez zpkZuisGpUxsWej&w$xz9;KcZaqNZ;j`xP7|2#3B-+dJin8;hIS922sexG9WApA{0&`wG&ahkt&qgEKgXbZ7+?5yjDr#fIHAQomt3sH!sY@NWvo ze^D3UKGrV&Bh5z`GDj1&(q*(XEuG11Gyh_=zr$t@JuqDy{28qVT`tR=cv5HZ+V}wO zHg~z*=_Lq>zGu(8y3{&YtF=B`9AfCMcTCmQoC&qx2xT&K02e}NKKQxGGpJiG?MXaE zKa)`*%E>;d&zq=Q89|Lw>w#z->UtHYK*F&=_Gl4{pK@v1it>WC`yKs%FKN*=C!yns zzCiGpdf}cSjuuVb$VLL!BrI+F$ou*2@otv;o(T+~W--$AGZhuaH0*!QoVpPNR1RTt zwQgsG>sNzzF(+C3!+=raF*+7FiJdY!JGIC6=;?XL;h|=AZj&n&D0K#-W6QLEnltPZ zym|~IhmS`8TH`E2Bo&r{k|}f+6acRn!sR?oQzqVqItF44!B+w{C#Mhh2`fLw- zr~Xyy+~uZC;)jVFkOzV7o*df5%L3b2-V<>$IBGMTB>Be{idM_->7d~@{+@p9t1>dk z;wEHZ2#=)V8K@@L5!r7ondiKw@=K8Jv}S}>+UgeN-ZX6;W)(WIoBd8N5!O&h0UEAK zeY*JlonQ0pqCx-iQ=*%On&$<-z{#F(XF;lN3)-6Tb$>xl05!{vB(N)rMyw zgmM7QusDu!;*&Y($y!9r!HFH6mpIl&TEy#nxpx%^aMIO%rQTIckRak= z<5Xd%d&q5l8~@M4Z%LMp81=tP2{O<#7d}t@q{*F?t#7Imaz0`YXp2 zC`#Lm2%HDrg__HO>X}hR{P7X@HMkU6xC*$9-_utb8qH`h|IVGeTekZ7E%)@u9WmU6 zbtx-Y&%ahAubc?ThEHWvyq)ZK=gL(7Sx*-RiB{zfAoaWY0Q%NRJo6*08E>m_uJxc0A#DYNUAQ!^Vkbvuo)q~l}yNoY;Q zn|;F?P8JT>g(1tQ{;W+K4P2>Kvj>^9=(6e7-+#B%ZDP?amJ89<-^j}ozU=p1hq85o98v&%cOchdNo|^~Lcv;L%Od=I+Q@BhdfOeb9|mNvV*R zfK{mJb{JG-XLAwgS5iZcET@;-M5e5jjBzdF^G?1=r>_Tb#g0xSCYxTXQa0%Rhl4n` z>FqMh)CgSWI&l==;rNgF(4k7@pzcaYb56_r5$)m7oCF`bKE&ry;7$I^zNb4vkGrO8 zrs9lmL3(h^MfLZt5oMSnzqJRns_fC+I?q_0jp??~Chy;>c?Nbsy7$}kG!4H(1e&l2 ziV34NaUhttA$&2QgsA{p;W1O2t@_kUiFGp>Hi{KZ8Hc6$Ia=*Cfc@GG&;0Gt(mX*~ z7^K;T$@FaT#I!Dx9seHed+@n|x|j#9*8}ShV;|MqyUQ<8@VK4o<_pCd1nKEE%5pj) zCG$gZBr&pE58fCOv!Y6?GSgrajlW-Iiy!CSyZuhUfEX2~b;@|TrdhUQ%O`NTml;ja z*>wp&2gs$5kL4Hey8V&aWb=j+Bp(4pN^W&^k>L5KYa=~1m&_z7)eT~)Zy5(GPkpM2XbBoP;cfW~(&q?&%0-KMQaC)rcY-HYE`WXg{iU~XEg_hG1 zW5ys8kPP}LS5_-0XwIjUIft;Mk*?`+AhRdwwBJywRr8<{H-!p?|-~nv7?iEF?`~E6+>% zPLLSw2I9gS$aMCqH#cm~{@rDdB$e2>xb6FshCTUKQ6Ki}@9As);yn1Q{VBDum{EyI zWzb$NXb|FVwdUKC`r_C@b06bolMJYwU-8S4k>!@A)(S6x%m*Kp$4vb9I@dyR#?uU7 zHB8t#y1XeK+le;8q<4JjywY~Q+IOG8nu~nRmh|kCJIPCV7CjD<*-OE0OdhyK*sS`+ z%w}(}-=F^y{NI~q*_74mtT zEHpBJ@8+`-&9r~|<-rn~|LMA5dLQeDRcL>ey0o>CU=je<5m#=*z9tfKyP?S5#wKsK zP);yLw4B$H#1&ER(!NG4^<*?ZC!Ou+_y3)lk7+_;tJ+)6Yr98%Yo9Ga_x-TCj~hdw`f#u@y(%PnF0u2^Ri6co3| zMZUqyXkXQhPcx8rt{xQDS04oON{s|j2ncmnYTvO#@It^O44Omyw^^Xh)WW-?pB!ox zF%?U*`seEZFGOSWPD{jIqH)MtU2S&ZZ3-ZhXCM#yPxEZ zZCPS^({@JFNWeYmV!CAa{&5gjZ&NYjsTY3_`K}3?*O)!)xAno5^6At8KIY})%)^8m@FiY2LEur;`s0QBB5?;>_KKa{vbna}>HkP0&CPhY_ zQDV9Upc^Q_x;lmcAQ&$no;&CwGj=mmlx}oDYjfU6@4t8Md-84QTuSz2e;ekl9BO)Qscz>X4On*#<@cB37XCaJ*qn&?WrJm;6Xr+K*efF&q+ z>k7t#37hzwYi~HO{*DTYLHYZVzo)MQyif|Sct507sYiW5dyuD$EX;DK%$DIc4x2Mr zL6FFIx|$^NhgPD-_L8PLd7juQ7KA?dl+SPZ0C$&dIC}!lL@eVc&KIT)1CBCS)|kJvLIYyjJyQp3g9rZU& z?~(Z1uFPqrGh+b%-L<1bkw^`++@jG@}Kf6r5{w zno<7#t_YHYeD>}TU)|fNM*-EL=sf=ph`wbSM`v7r4P3c~1&#cSjLgBe#;n8MupW6j zp;1N%N$7B#rTp3nXKxjt!F+yCkR}v7naGvlj}s`bAukRt)>#c>qU~Jpl;`uet#m=f zA%2R8SU#-A!DB0{FEs6y5(1OXkd=3}XR`g~5lF{d9DS7J{_wmdVR zQ+fEs$s;!#->vV=5>{#2MPW)1yg-}w#%?he>FgSrB?$$pUc8;@}RHi z$kKd0rev2Lv$`Fvqv;ho6+Wn|Pm(ri4(C&^09HV$zvAw-c@pqV*6gGKxUWR7fK(Zz zH*Ci5zxV~#`j5+<;*H?psfXv=54&M^{M!_3H3U+IfP4c^^`>4ClZiK$<|+pzkKjRH z@Z>n2-FM(Ancdxjejmva{;TeL+m)8s(LHiK21e`h=Cs#Y>SwkxK$;$&<{3*+*lwM> zlIJAwt+xdW!^n>$M;MD<@BZ@G9}X{V@?8 z+OVyk%JBM#6ncH9-$%E>ryfJ{a)b!)>Qi?FcscJU5vwF288s*lGt$e~w05ulIGApx znwqY^lR$>A+gdy`p}JZdQD~>Ap0qA@852vuX%O-EZ_06g><2;yF@xwuT{URF_1c1Y z)Un_Vi(d3p(3=HFG72XdcfJ@U{Te|`bsimJCkcyvruHOB1Qocz(M(s5d4U0McjI$Z zCOO;n3f7{H@=ZP_BRFqgS71CFjIzxASKgOdfNO!|E-TCE;9pslUP7HeV;s^j&BVKuC-%=ry)+BKBIje9)4g``dPGp zCF9^n;1`Xeu??m#V8pSlV-g%G^-;8)j< zt2?%&%ud_E5=(sin@4%q+CqO6R>G=e`BbNa(J2z|}v zZm>tOyc4sgslgxr_WR)tbPom0S~Jv~^$|>=@2W_UC_XC7VE^ni=U|oe1u~^2wd`h% za9{eBo}aHqto2oP&9rQpw^!Yz4$@@&K>DmCRPEJ7ZwJ#cwe!D;gLx#(p*wAZC1RRb zh?g7}$*MR<$gGJ4){6H`(c%elu)Yd%?)yf-0N>#csvvFbCI^y%KLgnlZzqJ-aDat3 zIS_!4$EDy7b%#u1U+R`*#Dxuj2VL`f8@zTSymRy>2cZ%D{F9___sxiMKB_gKY4q@n zy`LH!e^#KgVPfsEuz1Jko#KszN?m4SZOW^$T@3d-#*PWc(y}*o%eb(SUJmEYsl3xfL|eqbQtd)A^p?k}!}ILL$|q+&N~S!t%RCU5-= z=m#+l9skTq58&9{MfRP=r#|9Sc)IvaRdDp)xU-HT`+{YwNBYimwbW0lJIGp z38R1=M-B1!$#-4#)fZEw8$Uz65Mt5h=T=PNy*6{wf}FOfBp%HkACzVVgM;~!Mla0Z zWBjcsMEbN!kRK?n^tdhnV@s`E38I#=JMV7S{MdS>t|)wtf11s1K}*fO-ydC+$*gb% z6L!Ki5dW1nk?>}ZIo(;Rub0S#gd=*1hP<>~$CwA%D5X`i{KH+ zZUq#pVWbfX=F-avUPYv!%>(Fj|22BYGE1AQ!PE$+xeT+JQZN@r2PyW8zRlUZVO~xX zRr3{qE`N@`BWXZwyFv3mh1Z`YpUh1veVPfmY^3LZrq(iesEstpxBQ-%mltdCoy24} zSoC6;u|Ln>!`CNtK9$=olQc1yasrc#KV*Krl!bK*XOvgcy_QT-m3136&*AO2dgmHi zh=s<2bX;LzN?oU_cUq`7xR^V;-Vr&1R~esK@s|=QmvFUbgb0>~uk5{>qQ(^w6<nePgq%+Qux0Cj>qaZ9^BNBb?E8gSR>v|2B0Rn{&{Rte0ltko9 zl?4Sf^eMco(l%Wv+O@IdX83RDQ@_RDsFbqx5N7#T_<79kw=T=xU^GCfykgT&tqo8E zL&RsK2KPSv8&nc=h)At>mf!P9rBw@E;qfpS>)!lzT?UCC6W8Hh?oFkwH`L;!nHV*O z8IG>=su$m~W{@QbH7T$%k{6)-1Qt&dn68hD-fjNs3qcp+_#Y(*egsm5yjVx|eAJXD zZv=LTrtR-xzqE}dh1_pB4R!))Qj4P~m0?OiWNg#{9IUqKv#)K{j(I9@277?4Qdq|~ zh^X0pl-=lOjKOr6x1&1qvF8xTQJLjQou`!wxnx!c%=uGCHPo)N$G9u1w(JarccxRn z+~l4(kO>%p`1+YVOJyh;uWr2$$`JCW6!nIq%vG+*^uj61BkejU4M(eA2+@T~rRe$2 z6lj9b61NZWZ}gc8lIDP$5boq1Qv?mZqYH8A{>E5!P7rx+I+d>e6l2`%Oy}XLeqV^{ zZtE%^Il&B}f&OoIKHpE#dZbm5x0#Bgg)_IvFqEpEe7$gbB+UO(~rK!=(cF8$y0 zk5*S_D+KTI{;?RblDItq?&s!fT|6_RoD68KLvD*aSTo-hOdYN3iVhUuT^veAof>9r zs70`w{d5u>?eF}XT6cdRx3P>m3)bmNC7h%8eMfiulwmk4@L$YyW|+%N3ObrmK!6!^qu@$BDjaug+r zZ~H~f>DIgy0V>fN3`7BB@OygJH!qy1X^DU1L|P*&5yX9I;@dInKOW*6fbxiy@VgEj zgBss4lTm$L?D_1zd1tUA0QoR6F4y;j?p=Y~e}~^Pw&IX>Zy(?F0fP)ah=_mt&^BUw z=3rwHT(Qahx!-hGZhQ%+1nlD~)V zP2|hc1pTeMr_ozC!>9buXGG82-V}4oL-ctl>oS&rI+jk7kG3?%ntZ@{##OswC#tW^ zsY#Jdss`<+4}=@GJ>T2v3Nt~}mO}O}t|YCPkz#$1KF$hT^G+|aPSlX$cP^~on=z_j z^9pipH`{F*g1Y6uMW!eIe8GzPlDq+$^8J2i^IX&@NB*!Z!LkDva`f~E>AK!Uv?;l& zPI#@EUXNwS)XKJBjhvt7`6yH8Y$U>3oFt(3Gl+D|xyH)L-G<;#ZuYwUX~zB=X&hs_ z@z2DUxCl}~%0ju`A%ARiPo@t~$Dj3-G1tyo^CuR{(l8nc&0U`7h5~yl45%SFHNH*l z%)R(Md)w|4ulbVjwvI|%u_O*2ML8P!kwhXhS71UMktiKNj4G{I!+-@en>*Mn#<=8YfRin& z)uNdK6ONTk$eR|o2O@Jdar>S9&rlLa04^&Kjs2{(NEy3RM@zPP@8T)%dTutt2JH6RFasWMCYWsz%zw8=natV&9ZrAMcK7PL z4!>MdkJOzJ*28;`^*;>$r>Tz(@E)~{$>ee58lW6K?b0xMHh15pRvD^Ediw{0+bMk) zNu~@mv>OxA6Q_^>&V0kuE{QhkZD3DpqJ7Uhe6``wY1F)fTiUEELlFXqS;TIoV(Ycj zCya7yZw=!SZQB`oBrv)rGQ+)drb6Fm9+pXhLmPVP z!{0lH)cJ}JJ^t=t??=>krDD-~C%n@{5u|L7w|9PbwZpaLRBrZ)Dlvzb$NqZ?dE&sn zN=13?IZf$+%~kS83nQk%64&cvEkwB@e8O8i{yyrs$C;C8wZF>g9rCS^0!%Ag(fGaD zBu;9RNxL^DX3ev{3pUkvJYwhP@E;O{u&*}-IT%8LQ;;~Y1n8fh->_Z6ro|Y>nRgvD+pP}}h^t88Jt6Mh6 zk-6#t=^#VPhn@iM=BrZ$RvjFv#?Fdz=Q)=roH{vGwkB4>kwc*WYD+TP-VAiUda zl+xzyhyo>F{K}Q>EWpeb^E_t_M1+G7x`Z)22BV9b>|qqd+(Vv zZsxLT5Su5~<(-ts8r4t`db^IC{Vs;b71^E0Quoua`WMZ>&|YC7Z|zwffWLF(af=t& zt6TFOdGGYQ*s*H5Mi=X_!iQ5K-*2kIo0pfq03`Hzr(OcdN!amQxuwqT! zRYV5yN<{l}mhd%bA$K@kHKUegmK8bK^)?poGDqFBa44Sjs=Sns{$)4ZZ4{zrI)8%h z>OG0@P@Gp}+^*tHAIer)hIB4A>)F8p&vBYA?-!6xB5)5&i`MXRk?v07En+N^8zqB!jwJ&|9J1{ZK;B<50cp4w0whm>}m zu;O|aW)K%-k$-SMf2)G6{YXcDQ3!m`J4)i<#9JNTFC7(kB;4IGP*@uo^%K78Ib1^+ za~CrWms$fUu=vT{YDU%jJExpU7h&4~vF)&K8pg9DVUsYRz7T}+=q@scQ$L*)frG(S9=Rzels+qwKdiwy zm1(<^4g2b(aSt(n+-sVD9$NA%*{wAM7Mo1IZ6sK=Xr){;DUb-Pqra!0k!Q@Ll6&`0 z!+_OiVu6S~msqAWjf?V-x=Yz)H0$W``*z+EJ0&yk%lrsdpJ*Lz?@#CL?ql%HXfdBu z6Qc~l$6ep1kjq+gx>{o@F_vLq=9K%My(1B%{I{mW0^J`84drmFdA_U@t=Wqs(f~|k zx=z$t;f9}%R!!xA^pDKwlmd5|ZD&ab+n_h8h_w()t8e*1G$YaQ->iQ-wdNR1U0#Fj z?{OlO%86yWu@p_I@fcV!s>^zxC9$@xrR z1^e`3xzuX2U&{v#B+xkW(du{>Ss{k7rp!gNRt|`L-J5V130kRb+3K7RR_)QhdH|tf zzbc}u6@Rxx=F$lKB}p?Qu{6|t`kwyzbX#W48w>7S3mXWnwA1GxwQEbmk$yD$GQu-w zly!H0{!OA~f&kcAs!%7_WLM<6+t*(;8e| z&9ja#S@c>)c3#Runu{-oRg4DwgE>W*Q#M0Kj+x76P(0JZnsTyQSU4Ev}fZuM>a z8jc0b@$Md!s?1a=RvWM(a+DVTmRY)zrb^i%e0QsLQ)H~5MTFMAK0m`X6rrxNO$ljr zYM!e&<{N_9n^)?rwcee)M4y?)&0u?5xxcqlS<@!P`EFZRtBhlX zCRqSnq=$_Y18BdHjH9;BU31b5gRqMz_`}FwafA$CaZ+PRUL&;3FwwATzo$8=m6iDN zqqUQ}6285O-R8EY;iofo)e*~KTiMH_yfwH6)1ZE(7%?bt1qILX-e`rsbH_Cky|-fg zvf%$dHq@dud&cnvduT8lM z;npE#+Mbhu7ENL`sjFi9VXfSpq0hii&5E<-VROCJ<2OKx4|*Lj@&RIsK`GMZbS6RH z9;scqhOY=irZR>L<_qs%D(#i`rg5=`-(A+$kbC7#FXfSpjeP-Z0ms(|Y_00)ExobD zbX=LK3P4(;y>x{SX`}*wChJ%`p`3U~s*jR#X|SbQ8$+Lp&h6#&pv?Xe~jybf>2x9FhhfN$tLO9hP+87m&Cg zo_$JnoSF7kt^2gk*7hM+Cf$Ir`d_)s=Ed&zd#ApqA0$7Ep^nU&^lR3^TF||G+cBy= z#yFaLdV25BrY1C{Wv}YzI2P8AlyS``D_gJdck*Y@CMd6T!$XHrR z<@>im&XClHbMk&{bJ&TZ=BQvq9cF&{Od5y2gvf~xRseOUoqMkE$K(@RKF>9$73EFh zW1?=25@B`-4ac98h9k0I8_59wRgap}cV_-f?Oz?REiHAvNg|_q$Qeo4WK~Lcvtc?T z5)bHqxln!9gyc)$mh}s}rii|SJ)S9Eh5GGt-TG2IR8&Ye+?V6PQZud?Ybe64lqTFI z=}u(3?ObbSD^L;(;d}OV?q%wL#>w|!n+(dzj4P_t<1%8Uas%B9 z&6Lzz>gQ$Xn4qbKOZtOY>6&t1JlpjL4U!0~4~iS#8(ESL>HRw0J&ycWyu~cqQT9;D zH+Wkn?WxwCvHPk)W4L653}}u0H}VsPd(tqNp9E{W7;n>c&9`EEP}8@G;MS~&#lzn( z51lOZZ;^~O6rg^)XRK4pLQV8sd5mhzaYPkByNVWlnz%7Glgv8`btD8^rGuB-(4Z+9Y+oLr!VbE|s zlvZz{(c?Ws4D-hw=QropiM2zVam)1)0>n$Vbg(o=!|CHW?XEo&OX-;^DNAXlM(Fr& zRN8ke43JlMa=mw-Y%`2LU2%i-@6qF+Np($z@`Y;fmWybD z1IS9mdna4O!Pq#$^dhXkoZvq4VulyEp=7OSsm%lCMog0s9tjE+5nFHOKa#c>8!{;n>$+5YODH%df zL&?L8>4gLe`-fUzy=+Omk<4i~KmSSSd@iX?2^eSP%A84L_3p9m-Q+G|0_vtUgvyp` z8|T2~@4Hy+W5>)c>ya(hxhj7-8|2NSdAsXx>kWJ{3c8?IP{H48(izztUuP5zW#l9# zD9O;&!2OQ2yC$#hO2g;TFLDG-NP5Z8Pk>k~X91i?Igk`FNN3Auz_^AQq|{-?NVn13 z3chH!*d-eN%g3!iI->}UNcwiSA+%#jJf{-p6$@xLN}t~$B0uCZbGSPs=+81|Y9EY; z;p7KhCyM=!$$*_?O8zvM>{@gc_9Xms{d0a_BRuD%_c7nhM3Zm3aNqtuTGGLszV!Qc z;o_SI_$jH2Oi!Nj7gfURs5IDzZ$o4s_n7MOo{#-4Qh~qj+{Xt{24#8gL&HD>vHKq0 z%yk>jj_?h}h6=;);b#NLI=|NtxJUQ&@N9E&j}WHxpHL_+$-{&*R-+MEGi>QY+5Kc1 zU&qjI35Wav>gErmXnLyk^&DR;9eA+UH_{(@DY22{$>042hpITjjjz;Hb>gv2)hUx*c@`>J?`C`d-qmx8nYB8bD}5lHY%W_FRhZr1 zW0A#n?fBxj4J8*@z*n|?zG7<9V^nsAnYXip#)~JtpRuj(1$MtfNZ@5ykHJzud10j@ zA1`;6L#9eB3>4+m8Kv@j_OWLkj}X#xMi?8tn0w2ejfs@#oGE0 zx?=|kQX>?)F=OZ+8VCK-`{N0$(+PmSZMQ`tZp$QDX@M9)SqNv@zzz z@g}u<5ZA|b8Nf@Y`IX(@LN@fSxY^&KdI#|DLKO$d;6MD4cPe{!c)I#)&=DN}C`3x|A9kq19F_RB75pBQ_0}Id$7M!_# z#1q6b9T3~j#LrzoM^br=^xg#4S0KV$r1(~9QcVJi9w*~(#eI)H(^BY#Tw?A7jmPh! ztD!OCzmUQp143+vI=sqLF(%_GYQhK8sR6)nv2|dQuKgYzWbYp6{4Lgj-AdxgXz9fh z8hGE+?zTf-1t&&psqtsfOK1Qluqv~^N1q&)b-IhBbnh_N1H#-1ruL**){);x%0U;R z0xPs9XJ_mqU?Xt;9j2vydt1lXo?c>pg)peq^N8tN_F0}q zQJ_b{y!FGxE_o&?xX5ArOfrPC$**Dl!gJ~7(=!F&o>>hf&k^384x5LDRCi??o}$BR3+g!`IAHlw7PSS| z8FV@}dBKG48nJKsg^j~~_}<_H2cukht(@bpbvG(346*$Px_^H#PWob<@0$*;s;5ls z2Cga08t1<+qqmv(XI4_orqJ#3)xNmDz704Q||` z-_I$61F13)8Vr8VJ~Tf5K3}W@&97y7^6M#DFJ@pwBvyoOg_cG5he?% zvu|y4IpQGDIQEB4VkB+?pOgOT@S3y$%sq9@jwrpQHJHWt9EP8XCFZTGRq@$*|K_Ch zuKpExy?tg9OJTNW;irn_ybBWg%#GS`QW@I3KJ%53HG^UqAp57OX5-QifRFtb_HL&f z=)PV)YBj*p>^tq+sy~}%7LU^4Out~sX{MRM*Z-9@-zZC5t{)(D{^*jpP0q%ApOs|T zsU@VIEv=na6nN$ep6=j}Ekm7c7=BDh8`SHWck&u&BrT`vk4Toy#18=g9Hp!)+{-mH z6gs{C_=N!*wiu&hSp0isXMlU9fqKpIcdh_*s(H)HrG31kc}r!p%2`ko`h z@JS~P+6EDA9Q50=nhHHZ#m}?7km~B)hXtY)-YslB5`zlj^nz~18M%K?`{sz6N&fkJ z@8|Jj32ovhiL&IB5BykRL*2Vnj3^Nv4*sVr*|bsP%%Kw!aa3yXeEfbX#h(kFU-1ch zkp<<$O`v7EsTyM(e=*dyAm0Xt!}7mkYWQ@aPJeZ)ngPZ#7asP&XAv5Cw;Lc@+8x|z zuCD}bx-Wtub>@bv3by917OBt8MaRxr_y=%Dz+YFbclcT5 zkxfC*NseD^2r;pV>0aK(JD? z5g)S^YBnAvSnd@#Z)%D)=hXo9%n8yL7%(TljaK5Gd6=&$ydYOZ29l7Zhr4QMYm6^n zCPZSZG!mC-ykdq_hv4Tf6id?pq22cO#9jHGJRH~Z1fPN2dehE5Mnr488w1HEXdycY z@nCbN^c?@TV`|Kmx;@LDM0uNO3~qFw6!X7fr@ukCOqr$;XxzT%HL7!OwkN#fY28qF z?-|2M^6nC$jd;GNAFhYpLcLVF z!PkK$FL8-~qAT_{iB77BYNGFlyz7~8Cg5r_g|7m&rM|E;MHC`l-eLNlStXbQZo2_Hrjzy(@f7?8m*0mM{P9!(tF#Qn=lv@V zy1yaY99$jJJSz+xe^Z9n{a@Q09d)ti2(2KnCQb;JwCIRw+ z%<>BC1>?8~tqyeM==bDbNcY`U08nvcRt}LueXmhDLPuhIa7=v|Bd%eP1*5#dn*WY% z11{wPie-lwkv8Q+!m0^>Z`=k70@IvUkcfG8A6ElVp^_5~kkqGX+koSMTH5=ITrnMWS_1THsS=g#F;!2)Ka@ zC?-vZb8LM;R;sZqMaQ`P$qT9JGn0PyC7(Zc{bjlAiPST;I=@$6l_-bvl|W27H= z0MiTX#hmcVfpn_=pM54!fH2MzVxK=7t%+pk>`Wblb$3phGC_zDSc=ci8Q8*)C_?sI`ZFL36xm$*I=&>eUY9Zj#;%IbfaBxRybYTl(S41@fZX!7mpNgH_4F>x z^_1Ty%f<p`zZjyv)|&!txu`4=ht&C z15zp*0p9z&e3_P8g!S&_@vt`kHn+kCXF)nWz3(Q!g%~*Mac5?>DFazAjOa%k-VQ9Hk8x;Bykz8HOAFpGH+mV zWRmEB<{kdKD0F4t;v0JzyvPY$*H71A2$4|?=G@SPe;5vcbhdpFk3 z?L(2)R)&F*w?~<8T$1ZYhZ#w?pP@sJBmmIS^-*~;9J{$rKG8j}{vIR>G3D>yDKi#C zIn@{7xlG72H4#29YRL19d+4rAUW!auzW^MUD8ons`Y~3s)*`l#aidAe+fnqE)0Pb+ z^5O5C$Kh&(mVtLTO__+zau_xutbMXGMhS?+48gYLZ`*t~tM-h^@jm&a-UOsvMC-|< zJjy*_%w<>~oMS;tY4BSwbmogK;X*JN6EBnp@o=P_w6rhiPsqV#S5 zgQd8!6@z@V+*a{K%qEwf#kcl9>K8zi>UbMrqvbHIYNNG3>d&6E(qx9D$N7fsKW0^27TF0VLLn=4+)~EvC z;?MD*Il?Hz|NXKeB$ZkSAp_bu=VvurYGeG$CN6syy77v_oFpx>RD!Y-io%KZBs z7RA9jRh{?SDvc4n@kZ&1TwG6vD5MSe$J;dB4hO_Re7oO;@hyI^VT1&UE4+;dF1T5B zzYP*b#$g*882TbdGT+u+qJ@Q=J^_z(SnHlwq-xONy#A%C#4Hk@4W{pfZusS*c=88f zS-s|wWGYTIUWJ45F;s#>zSXxOQiw`_;d{Rg zw;|w1Yr0NCPPuuID4NC3$9EZt7*ILWwXMW8Np5$g5s>&UWQmQ&Kzl1@^j%oi@J81) z8$}RcVt^4(7ngEY{&csprQx%?$F3Qj1k49B#|zq=%1mIVHNCHK zPX=i5T&q&`)!^$I+p!=HxHP=EjM}c0eF4knGiQmI@AXmSJ+yPwg$hZkY~2Wsux(HY zeB}Y<$o^u)hV`9)ShG)+X3)@Gx&`pMg4YEm4yp_G9IJDt^?lDA-aANDGZ3bMPxPWM zHWPFkH`>>t`-9kTjfkhhM|DEuF9(jZ6)-pHwdCa3Kz9;()=Jmkyf_Oui3o3*2L4S0 z)s6ns7KN2fNE+}|jl;^Jg)=YWJL=+TIwSNMW9g|jQ;A^<0KBZO#)@Lb*9KC46Fb@*>qfOFE6X2~fp?34*J~sddJsGZBkgOr zK&)zT%u=*S^K$xe)PFy>Z3eH>;?=YeiLQ~YBez80vFU=wtyknD2CLiT@IDEekA#Kd zEp?4dVwFRG9bY@SOkI2eH~bR^>E<>D9shj+V}v^!1SicWYiUj4(Lz4uGgbg4m|Ok~ z4Zdnw*7nWtq+D53z>NF>0S*@Ey|!QA)XsQ&qEov(YShFIglOA-sOs;eP}ng*F` zdKI4*kKKfAqtiUr&kp5i=)_}wQc+R+HRX7`^AOUbT(DC-WJGgK@BNz<8V;!28r<4A=s?5Z~*_uabO#70W=z&Sl==eO)Q?6(L$QI+Pit(kT8O%3Tms=$S?W z=8n|0f0Q86VCZSA)AHb?Lk=&J%>#JiywNN}#;Ubs(_0)v3!2_bLWJZFQchc8Y+z%^qyg^H*1 zZ_xctbc>j$f0+=?LS;Pr0-A0dDQ}Ap815whCN8M}vKqhn{_*Zp zvALu)N@p2AGk=S`C?d!$w68h}ncxKpQ=%j3| zPhPuC=NXpZ`9#8v)@vNT~tbO4?{^z=iS?5O2>2m**?11 zR{+1mItI^Da)ART?eBd$_>`9;Sff_-tf`u&L5X9a%v7dWn3b!1Tm>^i)g$6N;5FW( zSs1#=$p#@pmvpD>GIbhTUMGCO)6k$Nc;uADBW3M!@qJf{El%do@vodW*Pb0Cw0-RV7ih-G*T*_yh{f&VQX55q|5I6mq* z5`YL3*i7{(EQ-YWATId=(^l^=gOzjQ)^m49I{cjpPZ3LNQI2~vytPT+w&pR0B(eXE zo8F0Si7R(yfH{R2YV9epT9wnvHhD%)6C74Fx%!6+l zZL}&R8fbe**~K>a;agjukEJJjW*k4yHFh&@=(6_Z{9f_zN*%+bocHhv=dcB?)F~aUefbzpqHreQJ@Kab+HaSP#RmECI&6Sz@&jNrOF7u)wPPNpBPIdd zhA}zbsH!t}{ki8U?r6;?@%-mV@D6`TQkX(J?0-y%-|d+}=81fk3 zfajW7=h$S3gPjsF?70$(7>bW!_J`6Sj{lzi#IbYQF^1y5N8d*@Cr#sV0pJY)U7G;0t;OfQkcZHB$lT+tv=pXk{l9JT`5moU`m<7;@_x(MsJri+ zK_DW2GC^2L1n1wMr$G#B{q5Om=Wp%99^t!3br0yNXVL}fhe;X64ttqbf*`oL$U3>we7%%d0HYOU$z%S^ z$uVJ;yz;qGMmgYHiNCtnwdQNwHO|PI z`Q=Z6zhb+?t@1nX&NLe2d6k-OTu9hGXxU@-{+GkE>$=I+FrYIKi#UB2p?i-YV^Lq6 z<#F8j()qw~WBFV8H9LYsEoz^Ib#U#uyW%MgJ#a%2+b0Rr{GY;;tok={{ODy zM_bjvN(`>STkZMeIfL@@?U;`TGkx;Bk7>a9ggZa(q?xO1IWWOq!Eea7KYvF}66Z%= z=n)d}c0Hk+c#22Jv`1`#Uu*|jCv98u<;A2{ceIm>Cdk7XJd&3pI17{R? z%)s)k#5=w-jCP^o87fF*70*A|b1EqyCQ0L0M&22ETuLKHkcuS#-t1n^9496NgpEES zFv;HZ;`wt5Hq!t$kl)MT#Q$8k?`9>`0nc#TmWwIyPmYJdxK$o<$@qFmIdO>4C?8oU z=23w>V{X*(pJTdDcV^~sMIxUmnYr6G>Bqn4%S^0n7<8&i$WE`Uv7uz&0l9|%9)rz| z8hif^$hD+rxLV}Ta|vj0eoR*q7)`zht+_uhK=W>o?ui}yW$Zl}*&f3e+tx`!^INuk z5L@uqTuevSGPH7>&kTNoiE#WFBl{Sr{JBG+1`0c9OPS>$L1Gj2OVXo?Wi+4$(R1n78 z-Kt9DC6mS9zuEqdnb{92a3UAfp8Yco&ID*FlWtroOBBcCTeq2Nv)%B$29ym^fH;DZ z?ii7UvjnBru|0?wc!hb`ozdStb3k`4Yu=hWmW4_Wz{!0s!-J2ktEB^#E#KYrXBvHQ zeafx%9NCcbo?8h~XTwMBr)Jt<;FX|cb#sV|WV=}uZ4EVF09 z!W-#JBvijS#YhvIx8*ir;(@f%T(}mZlTRt@_Y~i=KglPq(HgU#nw+L*{k+epLRcMg znN!VRSTh`sv%G9?wHhfqn=8`R~Gi8biwE-A;%aqA+CHD_EJmk*Wc2|1>cyF zhvJzAnXnfL_2!F%gBDb&QyZ2473;@X&6qk<(GNm})xdI?rVhsV3f76*H&^qMhnt(7 zhr!y<wtnD zG3H*T5YyZl5RqpERZ_n3r@WX^hd9|75mL=zCq-7+NQnbqT;>r-z3dr(l6?Gj;hnX@ zQ6xeys;)eUYuo?kd-vF=vvFJlX7InqOzbtR%C%sc6U7_eW(=48Gxoa`kj$_`NgjF< zRvs^(-nmnMcf=Ee?D#TNktAM1RT#lss&{0nl9C0SGiWysemcGRWJ|QX%GfQFivVwv3MvvRz z?q+ifD`3N{$xm&_M7?Ndam2Z#TA-7ffLPd@y23`hi}wEg45Lpk+>Om*f4+qZ8&L8~ z+s0A(RuPL1-E)-U#(jO6f*hB=#eX>prXWx0zhA;A{Wfe64BBcDjh7Jx?=_tR7_Ta|CTmbuTFPdk8q=9I=a=uHo0ScW&xb>AaelLG* z+Lo#DoRKOhb&>Nu<_WV^W0ozvY5{c z10sd!rXxxaaAc~J;BVm(1g<)7CL-6=ep_dPqnqL6m`U2fC4TK+cGHotg=T5o zN%H4|ry5{Sa`{vhtVe!bMdZQKoRmoHgQ0M>R^B@C}6@4ZO3HZACfl*WY`ZGx${mC63CwWG;8R z8ZZa0qQyt!q+`)@X=HHS>`CUD|+P@b!&Jeli-E5AVawu6A?RC_ z{qiLNGr46VDpKa2^aygmz}MxAeL(+FM>R@GxUH`NA{jG*@b# z_+_g&kMec*t?o}IF@a{#FNua|D|>|sYqxHxHE~CK=FUlv?th{$Y?UJEfw7YqLK{sT zNwV2J!de@R-}K?hw2Qq0lL*<3Y(pEVCi##IC5d8IzYklfK?30{3GbAV*~Ernj}h*j z=8KKYHX@ZJ>DY1&x31eBGlbkGH|F@)RQLiI8(L+je#zf}vLDv~$!Bhr%M2e5hJ~iC zNsEz&_hj2T>H0*QXZ>6JM9Usx+jJ$_UkZ|;y*Lyee8*$`l;@+6A`Y!5R1$MJXV1~L zCk1torK#$Chd9B*&OcHoW7tOJD>se#gk5q^&hO9hmx)=RMwbIq!H?AT`%a==9~rD3 zzP>Tz(F5JU+88lCY(S(tcRMEnzySv4HJ99GR*)wudfbE{0L$?noJ(VXe-?4Kau3P$IFL#Ynj`wearq z&ddKd(lz=e=L%7KpFH9oqlPChio5oAg4N*D(rCpNe=0fIs+^mjECjXlW5$?&=AS~0 z;m;NAaLAI};Ep18*M>n!C!&*z`5KiRg&_QC4VO2eF#xn4+=t(pJZKViHqRAvp>Of$ zK-NqXuo>mEr^D)$1}Gfkj04cV@NqXN{$N1LS*g44I!;ly+jTEw>BFSp5=Wz!_xfDY zw;U|9tqJ8kdf`&5*ucyGA}g2|U2AQo!jY?DXdYF{-=687R8S#&?BxTwk=Utm@*)Yy z9Q7%D`fN(Fe6ew8K5M(-{RCoE9+uj@{8e9ao}jCpEbMLNy}~n|hCw6J;drL?R|bfe zRu`k&jo$MCFN&^6Z1*+S`#l#D#F)4Xk;>BbzLx+B(}(7fN3pl%l#Ty>V=OVXEQz|0 z>~#s4Z&*mR74^woD)+J<<6BCU=vUYfqfna+YmtE{5{--OpR?Y#R zJa^D9Q%XXo_3ya{Q@ARbD!s;mQ^d%GXx?^Az{|l34PpEa$I6_GnGU7zIhHiZ;88R9 zIPX)%V8x@J|GtgEyE&mP_50`8*2G&8FymY9Xk4|rx=Jjkjef~@ zbRybT{`@AM#p_vg;yqu&lzngfg7I<7^@_@WC-^wE=(T(9<6g55u?pSx6bEIvI zO@WRYNp~I3Tp`=QU|gzYB%H_&As%;b-6UZ`A=s2$f2}utJNQ$!VP79s-U*iBeZCbE z`#Wl>l@eN4vy;U<%{$&d*T{Q(l@wCQa?$&vKVskrK>q!Lv0C1JT2iC|>D$QC+UsiM z8I=}rP}T5AbGl|v53ZoCo`H_sK-kHq?FLhER=-*F8~NX^THgAC^R6%Yuuc`{kJ@rA zZtm}D8Yy0>Fp*LH)-fRYN_3a$NB`qYPoKr7ab&-|Jh{9d9~h69%Xw2#g&Z+B5-!7$ z)73Ye@ALyK4PnK6z`gKZIqP*fIZ@%#8?yfqW%kPV!z=bHD!$csvBLf3b_!-bV_Bf= z665lQ8a3XU%jWp0Pk?Q4q2?~-+K%#fM}$@#?!!WEmWNM`s=obcCCzJ04%P^0JldSK zD|69Nq&Rf4pugn7=NN)50#{H@e`_CHk+zjeK6CzanxRDos5rp!Fk?Q>=Y?u)9RtG@ zK<3#1o2uWHZrlmK9pgm<%z-u#e(hd@0oW3*4wE4p1isnk!V~|C6PvIX1nz^I1f%}7W?lmknnl*tC8JWqBfsPQ?}=vpg%kIDVfO5kEpeM9%RIeJ*tm<$ z*_Pq?74nz6Fl=Wb-!r~{2hYs|iADTNimQ0_nG0u85hcSYygrjEcPh(EQ<)%tc zBOgcF`8NJtMlmWC=8byWyl+>;CtqHb%wNb%G*= z-1h_ltRtK~KT}E6*eBCmW@c@dMw*DbUlJDDY4&;)iSG#i7`0!+7|D?}g9@e_2n?gh zWUW}b4I=neqz8$ki{$Kl-fG9LCno^Y{BP+afdm9K!`7eGGhtg&+tSVj$#}mRTv7f; z@JTVwEEYmsr{g{|r==^W*RdF2kw(W*QF6D%fV(Fo zz(yu$;_H%Ch`M4etIj|5>KeI7N6Me+dsW9}xl5q9&rcV zf0nsi0$Fw4W6%4h6_Bh&j@M-OSE0?sYUs?|(zP^LRqMn=v*34d1B6$k*`n1M?Pc~~ zIs+CRCJfZ=SH^!!?|Hh2&uVJv2g9k-`ECUf%(?#NAoe^(>h_)*(oo-v!28_(gg@va z7y$>K5>M&t4L<6!al~x|4Y?w;=^dH48PeKXAo z%rLgHO{Nw=WCM`BLre}oK$LclUT@ap2=X{z&%ZR1`}z`W-r{btYW^O3CNN_uISHUw zgsOG_GZ+(!?3}@HUe}SP-j29@w%b%sncS@Xb2sW%dO6YiF9pQ@S5!Jy%JkLyJt7)c zwA)lE8QGU9(#BYuzu@26tGOYk9X&5%(_meyB%B3=&(2VWv6rsx`AEoX-b`urK!l1z zJZBu`?f!^(Oy|=$!na6xJWEbL-SNWZBy~rYaUY2rc zS@^~kYw630+;kvqoG}CiUVMS`dPcAWgeuo}BO5$J{@XM4!=ohIB(}cNQ9k^#$c>i5 z!b`?Tlu^FQiqU-huUW|N__?aFzt*FQnm&6JZBT)s1psFZVRl#ZeB=0KBFG$Ip=WP& z@+*JGWyHg-NVO>2v`PmCiqa@TB5lps+Vof}$31krDP4CC^NF zkL&j@o&3&K34NEWJ*LapwKjG!c-=qqFG+l6rCaW?=M2EefH-dMGZDyvMTR0xQji_W zb0Lg^se3gHB1p^ykbGFehHsI9(vr9U%a0o%vg=Yv17o_fm59>*5Xs>NplSbJWdhJ^ zR7_lfyX}N>M9^nP#%+a(>b$YeQc+5&UhOr!sa1){ttjjzpKD101oLm=iAz!~wZD_U zBMHTeUfxsN39?oiA&e%`48W#^GxF*R1+jiB|BMRb0h!K!@oIDn?DpuX)(;gUMS|QL zP=>eSbMWnXjLJI&vBcFb`+a>3_gA8Gp)JokRq!1<_f-frz`mv*!bLWnEOF0AhY;ou zem6P*{5;#68^m`~4ncFpLkyy30T(W|0x5x)+m!I+N(O0ymq((tpT)T-{#fWnD!9XPNdVipWmM`gb2bg4<^9q~N<>2<=~*h=P@v|53(T%X>J12iv5UIc%eWkQX8c@s2v ztnh4ZrOwDq#|Bg)%H7?3o~7qVFV; z7ozCkDH7D*zL~bZDs(Lxkj+)+Uehu0HNSH+Pda;1eT($cW|yW?ydyI3fMa7^E4I8z<< zOf;Tp{tFOK`MQrmdSH>d{VA!SB3Zt%kuKgdc_)G-|8%Fxo-&m&4D({3*>@jhqC~Il zT29{{dd{(*V^_$`(MZ+*qUnJ1JfAN;^3Bf1^=)A6ec%IVT1A`={#~I`NQC#`W&VE> zp_oelPSF3vlO9(J!AIMnWir@m==F)C9rSB?>(nv7efDjau~Fjd!T$lZr#la7;nZVv zagG_0iUzb`hS0V3J2oXYOVVIB*~u6OE(6nvPq#4+Gr@d9O)Et6 zuA+DH#~Vj~K0J{(Vdi!jW$;(SWTZ614-|!sY(BoUSwo^}ptR?u+ImWD@XGu>u%#lV zxkc%^1-lq!J#EE%T1?|gmLPs6NuCA~I2n%vM>-3v_9sS_H~vezr^HteyUBjvyB*!)(@X>yr* zV=|oZ%zC7gGkV|r8xzm)n!@5${_>gj}zdU6^3I=rdozvZ9(*}{k2AuKICd$uwNZ7kS^6r^F1+r`CywZCBX zjy7-D`Zs-SC`2F5J z`LKp}@YFJ{@uPnK1cCM{p-I<aAzbcwivazznS);E|`_2JUFl8GI{r)y&z@APgC7+y5XJ6`qW|Ge3tCxaOqb4 zrcPa&?A;S#%chFD6Fah70rnPWLEU$L6xv_4Xz(=8;U6wG+S4<|8Q4iE-aLRv3MHOQ z0$T>SEcy3!6u4L79nK`MLT9(uMMK6vaQXPXSnqHA4;b7DZH@j8Xg_DvO5%!VpCxcR zg>ikUQnasYBfqN=%u%$~^Y@l^ctfUjTp2C2Q+{O^-w`35`^*5HZAuzF*N_eNocwU4 zb}X1H6Ix#l#Qn+-5GT}`^XgxtTVNp?NIo77XoeHfo*NMH9mR&K12_Z-{N>YGTwI&=7yd7FFY#A{2W z&JJVf1Wz9Nux?b3^;<0+24rNPY4^9>Y58>Br~fcjuSn)!vIS z;aXFzTL#jZcyyh}Zy_$k59vtf;KrPynl*!+Oap-r$^zyGgZ-bl5Aeh-AzFcOnO4Ze z`^e))5omk)o~l^hl1-0B`Q-rbvio>Hy=4%;f5WgbO>zsX`#=XB6|_o5Y-_nA5p+w$ z`YoJcVt}g0@o3%qHLwSGqT*w7kQ zLk#YXTWPka^~aV74mYo5NuvhTZu1TDf0dw8y78kSqTOEO4Zlqx#SO5P)YM(YOcIBAvVu&0 zugACe+#SwrQSu$U;;dh356^;UrlcXcqyk(dV^$3_n8K5LV?l1iV<@lGu<&g0jK1Qe z3syP5QNua@4~hP#?D^f6(IXs0nVt@(!mQN2%1ZpsG|^`MHG-}ES?0>bPmzUtt)nQx zr$m~ivN!M>$;>GZT?XN?%>#2@qjG(h$=T4-TWI<++2_jBbQn>~pHWjTxPe)5U-<>-GZ~ zo9!C@nS~4fh^O&}4>>ijbt3JqJgf~>hBDA4Y0h9K97J6ZEiq963cuz^56tXOiM!_+ zT0kvXw&C748+h8MUh0(!9+uu7G_9o4q32t^>@FGsMh4e2OzeED)+N7~dol~y7e^rg zibZCF55q|O!^Q`yU6Y*v$S5WxNP13MlCv>2E22Vn=ykSa9EVJ+y9i7+80WY8X-w}m z;DQmyb9Dwvk3zMom1*@txIH8~;-pGq#Z@!u{>B!1%ggVEv|f{zV^@QS86u7?G`-sr zd-%9MKR}9i1QL}Fs6rASj zK_9TD$ws;IM#l{}mcO-Ep_t?yI(3$ZbN8^da))sK8X`>*KdTyd>t#)GoSHrpmv+Q4 zo+qlLUPS)fcLOJqx&TZeA>-=~+IDz4j)39H!Z}qi_ zWlizH`KZ=_3jN3eR+|UEq=q=1#m`+^1p35B1zAoure~88nh?t|smEe4+m|@~2WFoT zfZyFb8P0}=j>Ws2#|%vv_rXkGq=2iQ6i@#NV6TYy7C!-ag07$HF9D~$#K~Lql9F&{`TFLosgORW~@{jgw%y$_NT z(%xSb42A_yZQ)W!uzqvXXZ@~99tyr~x{sERks%)8+9#x2eZ(Jzw`rBFI5B2phS-xH z=y2PT7LELJjt;FFNbede+u!HSfagiC`Q>O-a~wr--(UAEI^}W>GNk#c-De%pxku>c z*v?Gdb^4U`F6+a}-Ne=m0`p)+7 zwe<@MK8t44<}Alwv!8tdK?M@{*MdID)atYkD%wKBYcHWe(0MfCng7z0n~~Bk{v%La zjZshxIv}Lw+~v<}%F?X-Y6bBLM!v_m>#_AQ1}O`ExCau|P+8br3_7vGN66yTZ3jB! zx8MOvwxa4Tv)WO42{}Bax@{Tvl;Y`&UlJuu7rK{ppRw&)YamaC^R6pt9fK9jIR)C1 z9bXj~@+BM zvb2$jR~@Zi$#cr|bAh zX}S{NT+C`f(MLS+7f)|5k?H#P*oWkQ=L46%d=7~4QM-EE-bvixC~}25&n$%&!@mUr z#%N!g=I9;XNnkcb-3%7kx({&%HnT;vekX8)zs%LlsW^?ch)F1dV|Ga{=5MLf)bzGK zY%M=o*D_&TaPzZK+5#aI^z^$))^lP#vMcic3Vi%^^2q<0{2`%v%x9w#GKZNk3HiKy zf~%Xb0puf};Nmd(1h(ab%}$`ilt~RETG0~1{kQ$^_~`rB7&vncnVVn|>hNKmXw7E0 zj(W_m;Z8Y)jo?u+7$K&%$H!bd@=z!Qh<9pI> zv^VX_BUkWS?PS>UKEE6c_xLi9O#1N`FxmIH=E=9Si#{hcP9-^>Gpg~CedmRQKe>Fw zMJ&gBjn$IzLW(w8%F6rF^)j2!#^55#G%@j7a>huNM1cPy|6Vy3{hiZWmm)V#?t)md z_rw%46M-rj9|v;6wJ|5<%#M-WlHKN>Sbm*QTIC5^s~##N)~k%`%nm<~T*0Yq%=`V( zAGunal#3YMCXBFzk|Vv;orVFL&b#v|*z8+-0Bsh2_)A4#Sv!~ufc6;e)j(efv22rb z1ncbi-(oj>6TOXDzoEm@;>?Yx3PIpG_vwxoHlMs*Fgiy)4woJkruo|{gW}2X%=m0V ziXp&~&9)xZR|akah5YtI`qwcfea80y`R2rX3sLd6Nom$(o>NIJd5+(Tn7FQ?7A6t(nAV?+fUD}h=eRO$(}GjmUU3boF5YyICI3(XDF-PIRC!M(f_Yj<3KOCQ`4cOnzK zv(W)r3e^YQ`Ap}i2A`SKyuA+yU!?Y>8zsuzsjJdRJijHjF5NYfkBjmC<0En6pjExP zn+{?Y1TEcN{wd`RZE-uOH->QeL@|Xu{&vFOb|0E9Q-E#qV%BcnThIt0kmGACGf_`L zJ9GT>-CTPj_nPpDMnHScNRCYZzjV5?EMPz94AnujW`*o5-e)Aidf*IQuj8}v*bFmm zNQk5k<~vzFgCnDTMd6*3WmPbj_O7!@j9!u8y$ZNa)62uyIazGIft5(&cv&8sqeNXj z@y`hrq?zZ5i1C1E!8Iel!@Y!s*BJ0V{^9tlp#t9#THcS2@N7fJWj(lbnod+lv142B z$ubTvDbpaw{46J%qTyQP#92hU7g|fH0-LA2Yli6?@uGiE^IL6S zxYvVbS2*S})K&UgRHh+G_U-_Q8D~*kk2|xflwTq4Q3W_z27?fh+2gk3e%0z^!8?aP zj$+T9_OBE4jZK0HvtHkFw@Q;*AQ-||?j_haJ|jSXm%ntlp*vvRpxSyUH!)vH?qY9; z(@tGS(QXLzc2xnr=@cI&#na)AK|SX0Azxmr|=n5BKS!TDctZ$%xvn27QMoefC zuHgQWrDE(ESNd1^Zx{@s9yo2r1_vPio>}{;8+@e96G3h|Q19)-GD@T_{IThW+s+$; z81|lprrMTLpuxFGT$OppY}mNC@bf2gvWk|E<1i zV6ml#Jx@B?-3;!g`F6mJd%;{=Brs$P|4n8JqaOhYZ~pG(Z?e3`gIFdnelJIt6@6Fy zEzx@~H*g*O(=At)B8m&!;TEJRGkx8%Tc%coh45Cw^Rf~5Pu}!=Mnxor%7d`FN80RUz}#bRwm0#*|!NF0=!vqCe9Vr^5pbtd*kESkjp?0h_GLLrN<@3 zL<2r>)V&XQ|NFvhJmZgXonFr@H*!RlRx#f~KZ>vdhhN zkAadrH!+S_BgYv2i=rhh}JyDQrRhseV4)X=~`!4*{#oL`?E z?srO3%{d*V>AU_ml+NONCmxK5vM1aDb3Xi|b z$_@hjt>IgK(@8F#>7(8$w~&))zX`@-VNubYlc+d~xUzzF6K^BTd(!#JID3yo)V-WM zDIcB!;z(0?X~hXMwe#;Ml!ULSKaFv(VVs&hAK;iLF-lS#B&{ZS-*q(Ne60=pD`68y z6%e;Gs0d@-&#-7s4DIl~1B@8~19U_{Z$TkPE8mzzgHpS4w>9mFf~+4Ayj<*!#&v$p zK8i5o=S{mKRZ;ezHbVF?p&tGcUDQ%J{;j?9Ze@K02oQvfD1dVorrOs`;0xbacSCiQhe z8DBv2WbxTW-<4WXjS!lx%)UeT!8U^ED z#l|O7z#EKFS1T7Tiwouq8p~OhMJE&kQ8|kc4wzSN&BocaBfPF_k+&8~aqJ2B4u6L+ zBK8r2om}Vrlvhvu<4n7p3jc4sl_`%2e1`CiO0gWDBOBk0kKit>o>`DgE@Bh?mv4*z zSqlLrW|*ZGo5-)Ca~cKq$v14fBUdjbO|=-=vFNduKhIWLuHUOgBHj1v>W%(G z(AU*>6}=MJwl>}2`kPyjGV)YD8oAcpMCkfz^l=~(#4BKhIA>@T&?9L)z{gQ&FGXKs zlfeAOPgj`Mx=b#`%-p@$aJK7BF33jY+M4g}e-FOSls=@%I->lfc{7uF^Y2@q$p~V=jaR^FJtnGUalXFyV|+$I z-m2JKKq5pUIYY@eI8unhRdUB!oq0CWGQ2a|SU4h_pNRQn@XVUxw{v;{SiH&YwuY~@ z%Z3OfjGuZIe-oSg%-B2@-a0ca)Hjqc6O~wM)G=!TA=fq+YLjb}p|flWZ{fD*P{xem z4I<{Kw70T#yj#-}?trnaam%hr7o;1THM=97f zU~FsCY*iU3IjGJFJooX0%5L|Qrv5JChIgjBFM>at-3lxqslj-vwbZM$RqL#`(mj_Z zY$q;F6^F4h`EXZT5kf)DB zv~`pR={((E&H)3q6wfHaug)@g+~yv7i`j8~+na)@? zGHcg?9t>997f5|!=9ZP97=v$;`}HYYb*hy*71MB z-7<1M{sO|65Nfgxi_Jxt$7sE2Jr!nR2CKU0?iIdroZ!k5OmVXmh_L4`G1G-N zbChv}uXM6RgmkcJuZ_=wC`69A7-AezOSCRz?R=ah!Xq3MQQX_}Eq=&ReA%B$mZ`?r zc*$}iF}v2;T4$9dc2l-F+ELr^jXXA2G}lUQeh1@??lAAWqXh;sX`84V@E#pR1oflS z+ChD7YBBzvh<9A0e+MXxfV~tEST-2z-*a~aBI?_ITg;S5w0wyRZ8FWAwhAuH2Qcb( z@MM{}22$(u>7jl*6>((i4h!Q!s_d7*E&U7?AjeArjF1K5J)|SB2A^B0fKyB?8C6yo zgJ!sLDAqY}eXE~V^{MtY1>Qy|foLyB1ZQG$VRhA4t^(KK8NB58B3C;z3^=bPC-*V> zt2_s)@M+jTa|vY-BSdPZXHt9bJ%%`D@B=o*cV-NLQ-Jue~~@t%Hd9lZfysYotPm*v``_3?tF%`(l+Dk=rHT|Zt#+*u{B zLtpR7Tg;S@M zIvFR}GeE(B99{GhxLY?5Sl|af>+!k#;HYMW^!wkw#Sat=WVrHgK1VkBK>sMy&#!X3 z8{#akDAuADX2KDgo?@`;;=&YXKVx71SFDhUt?{UFT-d&=wW?DC5iqg4BiAew;sF53 z#Db{o%|4WA>a;Z)Fg@S5_|Qfk$Shg4&u8t?n{IFP-Hmb#Llh5NE`sq%7@Yd1)YtEr zyOR#NS2`_gF$ghJ@E05s5eEl==pn7no6j#9U=ot69ghG4cjkV5dtJ5DxLAg-)YRo&$hfrm|m?WpR2Z#=Po*2J-V432c(I$xy!s-#^zpc>|)!IlA>jW9?|IzVHMW|7lUWv-%uDVj>M_P-Bq@KHhEFcad-nM$AVpGpX>fyL2JLw!|B5waxDo__C*6ZdR`R6& z{F>MJrF6iC*a^$d=w2>qSbTflHN$oX@v^Cwyhvh7RpXhE42g@%Gpi%yx?dS-?*3A_ zu{T;il;ioF5?DNdY2IdFq6zRtm$X;y`I1u{4oUVVD^u_7vWN-sUqWDgI$KXXL@U$o zuvZ~ljS*>QhpDSJ&Ty(^j5up<`uGmys_uwF*`4<(wcWslE#`Ukt$!^3erq`^kFIB& z9zs3$3Ad~SWX0q@hNBA?M=gZBcy~D=0@pm~J*7?LCd3ZKCF4nfIh#3dT8e z*AYr>guNRlE1}LF{hq zFeysf2c+%8AzUZm7?H3=8`oVwUZYPuy>}Vg1f8fIp5^|3nZU3#@ab}#SfT$sJQKeC zc8ibmxT@aR!oO4XJ1fVJ_~2G%;C-fHu_fTb1mthSgFBlYG`uHKO-H3~Q28g}uGs-o z#jy4uzW)kWV|It-hPB&dSVi;Vm?+@6>8ww$v!kDE zmgd3fHk|q7ovPK8nhTN!BMgU#d-na~IXrWpsz#n}iesIGIy&!}^?gyc*trzi_wo&3 zjD`6QX7h4SMxT+^=}6i}HUaj0c$T6vRGZc4phjH98BBqSP`)LETXMiL>W9riFx5|y z*Y1jBpvXT!>A603^F23~)+&~y_`c4nYn-20c=6vRJM8$pO9x$3(_UR%QhoO)rz#y~ zN8>oGqnKRdb98XU%!@hij3;HI@zGWA#5K&M$lT7eot4w-Np;9xX_NmB#%WO<1AI^0e*sB;*gs=s2t0&t zm69T}V=KQG%qCK+BbCx0AM5;Qf!ryYjasYJ3|}?|+roTY&dL&5S~FjAhrG zRUbVyC);$Yf-LrTg0h=;p+;h~rtrSTd7l3jrrOJGelVoH^oJ55qsm?8#YC8EH*9S8 zb6iN4`~DO1#doBInN;*|ttGhRbCSvM^@Z{kg=bp({xM}qrpH`}tOj+!J*dS7 zY+%DV+6VATOZ!n-qG@hgt z{)U*(a$>gR*fwaP*GIjdvpKg1ckvF@^eV@YW zFxM?S@BUa`>;DkZwJRCG{{G6h+FoG>WG+J`@8DIc8VzuqkDfp9WV_2lKSv^T2P8&h zulq&&eHaxIZB95kJ3ENgzSTRRQ+P#q{64{Y2oT5QpdU+$PS&e%C1Fm4rP)vMD&4i- z>iciyLpPfCY+LhM?s71?*~im3a!LF+Px*J~OlZ5J0F&w}T51NLa8gkQ`xiQX)T9;U z@_u1u&8pRQv;9LYN?aOkG6rBIFBDF!T}2!6E4!${X>QKnghY*Auo3W`UiJ-U&qD) zc6TIkZUP55-J@!;mH4us?_!tNb2v+>^Wga=J4+UaMaG!a}qDcwhmEvFGf2&sBu#B))yUMAB$%+1s-+wt^o!U2w)jcGP;xm>pZl>l#2B5q)AITWy?_Bw#?b;54sm^=c z4*5iwq$t9qCXiLp<@xf zwc0mZp&}r*@(K8WRNt}tr&DGIG1DKgnzHQ~Ep2(NL?wqhYHTXhfQvT%8il}?j^_$T z-nuUMs2)dMTzqvRWN|z@lOP(IqJFQs*4hV?!e8;dCjat}ZiG z^i3Wty;hLo5QKych#hn5bfhnZTiLb85KZqj=I=BY^8tNV?rrgq_c!fOhBh`G7U|w} zprOEGsJLW6A>f8M$H}tz=Fbw@@4xExUA}>6X=_-O`zLZD$<<;Qe%15zPkb?8z;)7( zn4*#RRD3(HH+|~XKyUqqcMLVQpsDj3Nt+mPeohMq&AQZcPsR;e$^>jJ%mimzbWit~ zXj^8kF6HC=TYVcMA5oRW?VPpielo1 zw~nU}EAYDRa^+2}Qpn|UiPB8t?C*#r<1_O91+~2aJ=n_2=vns}7fP-bxF7wJ>nAUQcvd#$VbN4=ilFL<|?i?8tYjT23?Jk4#LfvHd_ zgQw)xaZJh7_*3wM+;Z5Y2Eu`-g&)qnGn&&8jl`9u!^Ny)FTIDe+%VM-wrB?h?7ID} z-J8$uPzog{TE-)Mi`Y7r&a{;eTf5%DXPm_J zEhCDrp#Ck=fjEi?eIeo78g#UBW1*7qZ3>>B`FZfS&~!N3qC+2t0+BM1$=Gl?=Yuir#u+%AhQ-CqiZAtspcnYw-n7m(?o3gsgIQ##*+p z1cCdLG`{O(bF*#d7RZ___)Ve>GmzhQUhXjnBpuvzx613-!U%|kHaz>_C~3WPUYSxN zK&56fMlpWL2K3OZ3Rx$U?MId--^y;YbXSv1ZDlMH-EZ?aXZ(UQ6EcLbZ^y4gJY%7> zkU{=7!B>DyGQE7B??SxYG6TcjlYqh+(!Qu<50XV!d<$DTB6a4rtnXi%CB-#8rr1;? z1&$G!pa9aAXGwstr)b#a-#xvp$u-MBWM=jJEj}deEg>E;PU1}cMuKbg(A8;V-hz{5 z@BTKerE?}j(kw?YK@@`;oP;xEuMAWb5U%f8NNmiail_J-M&%VnT*g*;Jnha}z5A zuH38I(yN)L;U8b)zUN1-Zq-Y0-Dh>7K2&j-b7a)Q>mJ7EZ~*VC+L@3`JLgXG{2i2G zP2hyh%bzNKos)7K;{YW|SFjW?ew%j${6MSXtLuAtZ?rT@yBs&q_nkaqh?Pr@r(C}N z+Z}xZnc>LwjwfE^JLBxtD@($5nY%5YPsq;u@yO`XQ26(Cf6G5((Y)4`7es*5lRz4tA=lGv4oqJTIpc zr@cI#OG(b#_MX6*rHCBvMk@}&xBhlDb2s)CyWbo%kJu+hw#>$=ShlIkGLVj~Ipl?-aD}IO zXZ!c$CsFQc1mw1uW%+t=FTm*AP}C3vvbCffduoXY7efm!GZhRMRa0`dgVwd9mgw_bq*1MrEc~^A!>l z28*sM(sL0GRh!~G#r1pQ)I^Gbmc_g=gE194oidkG`=q~;}ncU30I5T8_=lJlRtD`B}kqiUPdz3cC2 zGn&Dq#QpLi-|f3#^$5iO`xpMGG$xMcb}+!xoe%YspU?6|haE*jh?^Q67;#J$^B5Jw z+*LmrD8*s(xjXMY(0BXTvh)UIU0bxqXDi3cA@6!DsPHLyg|jBB+3&H3GD4}ez?%S! zKW~aFX_>xrq(qs}yqTvDoJVEDvh^+@vJ-HpR`f+|L$150&qYW3wEZMfl3ZL2;~t6N z`z8eD%M+Cj-4|n2TWUvR{tazCMK45vfx_S)Rl*o$*ER3N zX(Yjeel2&xRGyCB?57i`ikbP?!J`n^+{kD{oI&mszdMur3%Zv}*%}wNLZv{5y@W8T zGZB{Hf9(bEI`cV+zw3(n@KfSBs4RO zc=u}=RXn_E%mEInrr(BS5#nu4DfIx3&O~cq?_(VFGx0x(ijSN_aw_;v_eLR_&l7^o z?)o0Nl(PV4yya(i{jY>DS0Z1us!jr3O4yr-apWAfD24Sgq0?yanZPYsvJBIzU?S7$ zHU5ug*`2~LDZj_veq9j)$)QauaMMQ2|GU@UKYx#R`#3plHi`PKk*EWAM`2M)MkSf~ zA%>dQae_xpc+OESd~Pc;LMS|V8*Vex<~FH)Hs74$2)vUE*z7%?7D>^+H;GciYf^wY ztfNt#Y+{p)G%El0Oym1PhX6WxQ?^sD0;XXEM;ag;q}36Nti4XGQsb#NN49>%oE9SP zm7F&R0<0`#X&bC^w1Hsdhc0KbVDO#vx2iiJ@WNCB+XJN)Z%4OqV_^V&cx@x?2e2w z;D1g}s3A{DWq) zIpS4hcz94$Twh*noLB`4_pMRQkV6+|GL6obuwYNo_?OtMFQ#AEu4XS%gFVqh1NRNl zk8ryS{MbuQm(Pd?nQ0$D%)WZ^Qu)SWba~!SX%FA_&hqFgo7DkF5RgOD_QgB5r{9@` ziut;maF>eY&+*yS^PgiyP@;fN(vg%?zw29k-Sy?ph~gOBL+60t+N1+~B2Zrihy`hqHFrb-IlD zl;PEk3LidNerC->%xxEx{J>IFXjD(^1K0m`irmFjkpD&Mh9a$^Ez6(zmOkgwUZ{r> z`E8bjFRF1@Tn%?j)w$|q`mTC;u_<8jw;TFPE8LQQq~gz)N1l)TayaSOuR$tI;&aK0 zbpIGmYY{rsDpMUq}oETe$FY`$p_TEfeDkio!yG>FuHG^sG zL~kZO)=dGnjYpw_0(6$~%O@-a=AchHc0W-VinNrMeJE&bIb?Fd{PXOsCrW77<+}u-*n;#S+(3ui&rcjN|f3wOx;@; zqwb=6^K6n~_rW_%1YMfTwFq@BJo@;Pn|?OucW8f(K)MV{_|&%J9C z*ic}t0Gs+qy74v7qjM71)Plfc=q1yTaHf3wa$FbqIuE2I%BoB?|=Q8`c`AR$EpR#?c zZAKA-Mp1PAl0;q&87kmc2#S1_HRm-Y9M7 z@I$1EoI~gA^y@QW*Dm>8Wz{6+4Hs@tWO5AOhn;mfBU?lfL>Ybna$YE~ z{*Axhw;pc!>v@i`VSs69-r0f7>1PO+ecSn*-`gXW#4o-+lZoQSgPo6|60lcFUAD~L zfz$xJ979iF0-LL$^6Yza*57}$Oh6Ov-d`M*twMtPB$oXvp+@sl-Y(n}`=!&)_#_S& z`qSTWAWP8u{Vr2Rvph2#B1i5@M76obB*-PpR zM|x(eBf-Ity2_j4{_q{d!0G4dFm@R^;#Fz7nvr)omu}8~{E8Ev2{h1{Y~_I!qq%nA zqe&jLmf!L}Mpcon6g_tm4xwJs_wY(47-M^PF!{R!C=UIB){z5O(v_)moV*9V-lzp+yiPBsa8i)Pm#|IWGY zaKQOBncq(ix@XBDqD+#wHJSIvRgk2++x5pZBV!V)1b4XpJ=`HfWPXh!?})DnrA}`E z?jZCH9*OAeBUIiOkDX-K;qaz(^%4HhnJ&eJo88Fo1i0Mdvzl>!2THu-i z2*_COL@mZ|59}fa6MkGl0cI%Q((j@4ojHh9HyuOxM%zBI9wru@)YXAoQd_1gK)W|5 zH}n;{vfo~Ag>EddEB#E06xhV`{~H5gSA|gH^sho{h@lOo zjplCn^c#wqt3Vbj6{!!#iX%f86mQI9*tOab!^XYuL z`N3m~TvRfa#1dpAQjibLm4N$RCueU+JQBHjXy5ARuHtEg3_$!cOaoBRS?LkT2g$qg zLSYXJNI z%MD^xK`^;vI5Re2KDud58OXys41U3EJks?SfH1*zxrJqBq&RR~t9`F;YrGjF0A2TS z>Tt#bpqV}mFojymTNToB&kzt!$x*i>oWq90jQR=b@++%DA+K~4^oVl0rmW^{?3KG_ zc8taGfINKl(DOGAl!$E6}sA6fioDD$w2hururiI*i3R+_)Mm_O@p z@xOp&XUz2_FpX;&JDy{o5zze(S^)ahke}D2b21;3W8&o8XVU#Uw1F6n7{^UcAZ=px zzuFu~;3Czy&uA3C?wpPNq6I~No^qpdi*Ym6dQQ}i&*I^SVogoEU@U! z(`E=AO3pm1d;H!KCqTW=^VubzTT{Dp!qx7-i{%}RpX5nm+x>Ce+<%YDjg~) z)<@$#;6+uoH^&%L$D^D-mE3vGe7|||Bb?-Ql@!~g!aeHNLTIwchefbT`_7MRB2zG; z9B1bQbEx{31a$sPTpE2e?F`9O8XZ=vP=s}=rPgke#GjJTs&AwE3d_PrtUdNcLFb4S ztKSuR2Qsj}=r5B?TYXHXoLk`UIrkK|_>kPj_rZEiYK!hoxz{bojYg>}FJ@fU_M+$r zceP~oXi82vsQo|l`pRi{y~{Boh!NB(^646%PIQ>Zq;(M_X^c=<7VW^Nc&S+DEP7 z?-Kr7ebY`s0wP@Aai{(8V+CdiZ}_ld9t4e)Gg_xBVsr72zLTa%I4$YKMmOdj6=cSm zaUxk7?G)9vd^WQ_F?Ti;yJP(qcCN&10(t)FWJbuXVm!8N^gmA!9wBV`C`$GT_!TY* zhN(lY!9kovo-^QxJu`PSZcPv4(|MG zwZ|H8To21&6tDyTEe|B=8#4AfsRHG?)5}pSCg6mWVp1K&-e?dte?`GwSzFih3ixzV z=K1@re2hf$IcLc6$%IX%p|zJ$L}u4Z;Lw_qcT#kC{uu5`NbNeQjAu0v+8;a6)2&7| z@tiWS7Fiy>NqbX`EO?B`y}FSB>wR;kn7tpT#TMN?BR7t5s2Aj+POty(`2K~ zz$>5F>yZ_d$j`*N92w^pWT}?_;)~v_ftcJ?n>!2N8AcZ(FvgNYZLBS0pI}V#@lI_O z>5bPAM)Af0O9;&GHnvPqR2@HfxOQGHHZRENwv*zuqevTaC{ApeQgRtju^~F*Ws=sf z0*zqal73 z*2?}Oq`!-Ftbs^_AXxzbRZlDvI=G%3jb-1wnNfkW?|JU0F~OPUrOxPg&JuZ@L79oX z1lGZ7xIT`v&T>3Cxx5YIMNcTnV${lpDE@%fU)~3VYg{r7K@eUHvH#5x#xW1|!vOI> zC5W3Y?8ig^&T>rZ$I6rGcKy6#$4lR4CPE=NhTj3=Cg9HR30ltBQgil>&$<`ODCEts zIzclqyu@QBp#n-wx|D^&GK#=F{uh9`l2Ad@{`Snck0SH%t4lsPizijXzyE^kVBg>r z%5v6(c^+~uGJf!{>ifpP*A#Y{Ap(MQl6K0iu0aeEgm4_vcMc=h0BYy+MudfxY7XE_ z=}jdonNWZWmM^CB!wXXXp4BN}B9YXkFS^Bx|aUwT7K^^JW+{r)r zUQT{3?Pl?@S*wTb+aJu{8FJ4g%?V;6>|ms4doX@6e;0>QTSii0TADIs{z$y{46lHt zluPfG4A%4)NDM>R#>Y>U23KmLgG-5Sj4lPVC8vo{u%h1H-YjOq!W(2u#l< zHE9w$kuqV)|8-%@SD0D(6=!@*7{QezE#l?h!pFl!+y=8{T7w ztAJu};Htm&%TsFND4Z#>%`m@rG6?Nz=Kl6REuTl?KEh4voq!KbKL}8c&oSQAFqV&5Z6cK9^)hOX-j* z7(4>X%r{4P0gdK>$SUH-zLk%`fe8bD+-4;qJO(=W=iJCh~{LggUjKalFJec!wqYJfj6u^t=$HFIMmhds@)wud@&m-T+$`th-nPGh(4b^r#ZQ zexLeV{0#PxmUn18gQ6Bs#h)kkGDA-$NnU8PpFxP~vzD0`F58%ku?gQQH@=A2s5Yf{ z*}}7D_Azdj)HLeNjYB*n8aq!tRf5`H+oSS6xpDK+Hwoc(JZpc$YmKGW{L9w+Lp{Oi zLS}Y;hGB$g`3`5chhr&?yuIeTd{(!=TcU?EiqAew-57)?*l^9uJ3ANDh+lQPM&zKM zVm5V@0h2FAIJOc2*FHYC9Hid|$U^5|BHSBG&nJINETk`v?brDi?{?f)cDv=NS&G~h z{)cI%C6-S-u$hqnyUVd!C#%=fpgV)SCJvqfgkt{vrpnZ0`!l17vv#Pb$`k<;WluW7 zh6!4Zf^;u`i;vyID2w&7RR(8wXG?11yeUB@z@GmL)po ztleaouIruaXr)0`Fe&7w1Hg@=DweY6=f~G%DYERLRdbIP~`V-RNt|iDL-3J zw^P-LTt)kNW3b)vP05$@6^Em5YJA1T#JR`vnWhkHo|O1k=2uQUIT$&g@1s)M^tntR z86gu}cq(vBdwtv4q;9|en(BA)Uk4kVnS*E#u-LxEkLJ?Qmq3_8v(oBr%qT+X8Z0sc zanqio8C8$Fxr34+s4=@|GR&XfDHm4rTW#YMQFoNL<}lkN!>E#PrBcoB*IA5U zrO4?0i3-!i#vm4$%HQl$Hj12y^f$?I5Ny+~d?#~|aEifVUP=Rn+af!N=+}4Ll7W*bqlf zc-n(o7GT?F!q;gOJjc7P?gDGM%2)C0xf*D@U2*EPShP(DR_ppZW8@Rk5%N0?UUCvGSNB66&^i=JAGM14~J% z_uFdN;E`BbJ|uM<4%T6lULL}vJ>BKYE9Sr)bHnZsO#C6OdTANa<`Pyt?RnW5MtjPk zYyErjU9fwuvrDSO>uV>Bo1P}%F=|bcP!-7oMg)H9Rqep1$zxQuW;%N*l&&BA#HHwH zF+u+oNkK`;FX?+-CmID$m>4On5#2ch3IwmIYXr|_7WK?S=U)f+e_9U)y=rR4m5gh;B9`06Hz!5>$97>suY!zmuiU3VU3%6{=KG5G!+HGsgZX-j(^~Do{js^k3eTC zHlzsL^bi_0zC3I0%9H=fPS7x1v09S~ka6+Rag0gdWZ4kJ8v#v8cYSN$hiJMt;=u;( zfS}j3i*~BR02ute*fZr}HU}bsA+Az54>g#(k)_4)9sYrKkkZMpY^sneHHIxeRF=zR zq$VO`rv)grPeKVo#j2y?2)_hmT?~N#_WUqu@3TblvB<7Y(sM!)#gz5soc(K_kN4;k zcnku{-BW2$H2%cDCHg2875%eYTSk>4WAMTrTb$`0xG zEo<-dZJwd}`XzYS3rx@K4gQA%Rzg=^nwuzzj zH9i3`zg;?^4>Wf<1f$*cx0J>R@-+4L&_XaD35lZ+fMu9>mDQ+L{9F6vt7Hw7xBEJS zeA>ge@voD?cWuwU4cC~x)ToqT;(0Q3j5kc9X^&G@MR?t_&V(niA&Ha{La;U4j$X&L zJg~D1*P!}$Ymm=CrxXTh?sTY{27tsjV<%M(qE`Qzp|TzA=9$TaYWn6u(- z%`ITAQCrUR3=;7hDe(NvwRB3dn!d9N^JYvyBwzE1cpN_LE_RRyy;YdBVK0jSq2Hx{ z@hZx|I3cktRJphPqd$DOLlBenPbQZ4Gl&xFI%dck=SD`JhR3OO{5FhB;6M#v2AYo2 zy-XS=0IQ8s!nP|mpVE6h|IHnZ8t51;jD8o~+mtEe@3|8^u|E^(Xc-RNV-Wp$B94i6 z<&AFR%Zw$`mD3_U8q1wbmCToOd>WbD(>ebl3U#T4OzD#vcXcWN3B%6jTXWqhd z1_kF5UDjGitN-2RfrQXFo^ct*H!Cnh4@4w_5SD&kVpkoCb4I zyf?kALXi*jwiK;gz7#^K@qz2HQ!Jf_*k-rIm&vkl^zy4{4!Uya2yy<%$4FD6a__gCdg zki04uh$9SbogUY=|HPCd6pI`AOqO8NtITBQ}&WtZrWJZfmbt8K!?nU0U4X_wIq!B7?Pg`Fs0Z0+1|a@n#Ea zYI}69yJ!noaz<{rV$8Mgi`C+;1M z5_FyKh#bvr6^KBifv7 z27=^y+^?A*8@JOCA7xr)rD+)%$<6JzriN(7desY`5h;|mOOuR)qisK$;vLW{jL_sO*LS2R#gc(+ z=AVCIXq*Ji?*vHOk2T zm&1cI0z4C)4@e$YR2!c!ghO_PrQ zUC1(VTLD>*bYdwZ2jF-Wsg9L#4|hAjo9VYrU^1D|lAbzewZ9D@&~V|&&NNm$`(Sro zIevzZBIPk5ytm$8l|I=C=4afo3ZZ7!B7@7oG@Dx|HiwKFB9c4rN#p|&NLz8A6FVz{ z0nkj1JzKmOL|bWgsXp%3qgkdOnF>Z*o~8WzJ%49pK`y3E4RoYQ!OWaLgc~U<@2hM8o4KXs z$7ZH@26GCbt*ydrTZ4Oj*S?J?x9!{QM4Xm0AQv>jXegdh2^ytNV`ng(Mt~rHy3#=c zy8SY2SwPu6`%5;{$@)@OkiE*Z+(&PT?5Ahi*yD=ovFpH=1Ipr#TO;)RJ;DU%mH4iG z-mG^v8hWGiGfB#`FyBf}G0Q(X0CjltfabYNZ4J)-Y)8G>o;BTKCI#YQmC77;EiUf_ zJ{+`&`IRthC>_t3b@^6gswZ5vO)SvkIw25ZNRInEBHMf!rz{rdmgd_aNdawrVdwUI z3-|RPXcEG3%p~S6ZDz|b0&;na?2MC4p|hToWFA?`H$j4*-VPCkbxrA8g$D2wm<6aZ z9u|0$e4EoY$}IA(z64JCys7Plf*_ia-)Ui4tX+B@2YrT<+k3#Q2JvC zlFm+Z1m^RUE}xdfv&Hf4*A6R|ITW{Q)6eWr5e44B0%p%NU>LazQtI%^%8L< z+2de3DhdAz_iB1;ekR_0RR<8vmAp4nQ9jeg0kAe_ChhkFIR0IIuhjzXs0f4|w0H0m zW(PW+(a*n~p)#nCYxHU;eJC&_fQ@sg!zIAf%;gV_@qRxoiHDnKME`_pdXp5aL|o7q z*HBC)xjS{+6>wJdS~YurgAwAFeV0DJ&#-&R4ZX~Z6~QXjQqwbXwOU4+KfBUhX*>K0`)^U8YJ;|h^Zy*lic?8s%bC)7Nqs} z-^(6q5cXaCJ0)T{-luudKKX}B$Zjv<32nAV^;D#F8V;WTY^j1q$}_R|7hC&j4_ky_ z7kJ_Cx}#2G0YfVo+%l2xXot!v>#3s;-opM&fJDijo?xW{o;l(2JKgtX#zW^@(u#Vf zzRA%2XoGcKL;ka>qMP3k2m0eM-u6gu8EaWipnK%$EZ27{IO|my~yJzy_ z9o!3sm^`=V=N3lvvn937`a8S6iw(F-C@j@@>-$txRJM_z@1M*GdWVWYs@sg=)*Sr& z1;VrX5y0H7Fp^QED>f{4)yvmR)-EO{d5Tb%m5oB6w?NGt+@G}}S)K+tHr(%%ofk0z zof9mDcPq@*d=BQ5gtPhGL&0yl zH`>`AoMD4j7`G397Cb+|jr4#5=PTy&p}slFs&jc&G5*Z4rmMpjyN z-TZUkbD_*K)Q!jKgIn;$f}5hh%SfSm0d0)rlAUl z)*Sb_8zOOHsMC%V#t=`ZodwG5SpdWS$*umHYXawmxCH?juI_B$MGT!*U8%r&o)Y5t zj*|%o4rK@ZcNyo8$?5MRFM~i{=Mh%FpockrrxqmZd-@&-g@(^#QjtQR;LAZbvLMZ= zX;JfZO&eHZE};&?2F5rz%lYh|hi>^UFc*`;2kq3Z{hn-+r%1<6l8SLsKg)Q%^}yg> z!N^v;@BZQNjS3H`6aJ`Hk4-jJbNgJ|X?M97ym@>;4jgz7-S+%Ewl~d~iME*W>vtb? zpvv~6n8N8>zu6(I!==wy$DiI?O)(;yWJcuN3OOM@hrZD(pnsk1z|qSPWIn8az0(#C z+y8pMj94n*qG}n8T7c&IzSw3B^T#`{x16!d(*4QCOc_9ay_c`< z7JIe;6U!jhD|03a@wo%nm^<0{F#B)Elr*cYjCHh(hK5?>m~oEc8~WW0mn(BKo?qhZSf{F=j^rY|ICNjY4#te-B=GQ8R z464hNDfbME_Txc1ZjsollI-M6Ht*b^W1-4_nT=nc;lsD3H(T{Sn|a%wQZWnTC}t z=0@>iLL`=u^xfs-TUny7kYKxET8UX8fb2mk^p>G9JKd40$Eu4$21!eP>r?!#0&x2i zIx1EQB>%IW!#97jRhryW$xlj^()THUuXw2s4byPal(-0%-<`3KvurP@J5giDnxRB3 z?>k^3Y6BIjK7uQJa!1oDoXL(my{W85i8`B39Xp9Jy5=luc+B{9+_>u~!5SWNWL$-c z|KCS*G=RHsk>`tRofUY-BMhx)?RI&lqA=&kmRcP}mAg_3j=r%F`-RW@oyJmKCo|Hi z|6D^vwb{E0aXqJ3+WkF7n6tiGEVYa&O2ySQ(3e;$zrX~k{P-%Egd$%NCv^3uvAg`Gb$v_#E zzhe5B#M%H)-nHL5{EVh5Nsl8sOGqNVC)nDa?Jp}BlSiHD6aI&cV7>_E=s_dkq^esqmQ zzTlXD*PGnI=aCFt>ok$LOb36@rS)8W75hGHkm>lWBOTgGkGICYve*sbf%$v#hP?b3 zXAft5s1!?wqW)_~=Wg1}A32=9z2j zI-5Ho5+>wzVvuBkKK8PD|BII4wda7pKAL~`fTWjF$X}MF!{@3xIY#j&yN1wE3*Y?a zO`1i?$Wrm6vZACJ>8yoQe)q+>j{Q!)=N^jxdgc*a>2Ppb2Qq_f0T8oipj}+ zFyS{!eh0c!YN(}rr8E>r`K%+{H+0dKKHoe_FW8^7rsT`W%n8~sVJR5=j7DKxMTvWU zx5)WLBHa1LNtut|%5sra!rG7mOdo%fRq_h<*A>ydgo2&@P3uI8dgIfEHAmznuwbnC zSY^Jf<@oBXWBPLjEo^7o!1FuoETtbsprg(}B>FkhP4@@+u}lUs5*FS zv=Fez3dp(ox~qx5YtTcgUc>M{CXb+!ch&UnAHC^KeC{R~<(oW56H4mG_1g!>K&Z?! zz-5X4u6Ew_=HhSgqlv1&E%g4rWL|}dh>`B7yDXh)bf;@BoF|dp=<}^uinzP)V)2Yu z|4IUKt#zn$cbo(h_ShpiI#@qD@ddR1dy{xE8rJ(r?r&Idj!4$P=YTss=ATj7t%+=3 zxVTxnSoQ5io(^gY_cPn?_Q8^uy!fIJp3hDe=u!lUSMsPSWU{dM>)KC!)Y7 zwxvo~mu#6#%Cwe+^v1(-`bv=e9mQQ2Rkaf638`K6pdc5l^7nY2(_0(`OD}dC6J>ckwoU|@SW(Xr9Yd8V5k%3XuHV}u z`8B5LKMONWlWNgVWXS#YNE#uJXMIxn7xG3P=|k2{#EAmDd191OHGo} z;O~Di=Ca0p#_5XN&@e)AG>$oesV07V!_>5v>Z<}$Cmic$&s{Y)%yNBDYb#Nq%kOv{ z(wy%f!hAFPY6nce%#7mFedrI=ID^FV-S1I>L?>wdqgu{bD_7=O`py%iNrH={UV_!< z_?GLmr9viAGbC9!z<2u_aE(mr(#^J}=Y&>9UokE))`>H>d0_9!_<3XXPd{ZfcF8nk z+x(5X2AbosEr`#^cSHf+6?Ogf(#WDz4x!?C+*eV7kNlP%}-{rPYBy&xoh|ks*8j2jVm%efa zzrKI@6`J_j|I2thMjwAYeYQPU=a$U)o}1iO^W!W{l8D9K-#tvn7S!anM1D-Kw#atFujNs{*2({(yj4}kwxl$;(U%YchyfX_K z?Akz_%|LuTyacKEJ#B}ZYqtdPGte=5$#ZIuZ(612KUJD-E^E!4r_Tv^O#RdT$jAql$Z8=p8F?8P%=C^}lZY zC<&?CI#gnMAoXv^B7*38rWa_%^JV4)s(6&D`MXrj5Q!Ch=K1dlYbO}qeJS)1ub$FI z-i-0z^;|;VA}Rn9=($e!NKPKewb`k&*_pw_dn{XGC~ z6~+XOv&Q56w=^+5v~C(f%6c zI!6(O$(Yq9!@H*DpgGLWJ_>C5QVBOqGXZ@7>^6ILM^y+7uK<=ZHmunMKgsGYk#y=^abs=qg2gD&m;2XViX!zi=#n!nN2-Am0h^w7KGx#j+P!UjPWKi9m|NCk+e zNf+yUtw3DrQw7m>Lyh(n_&93LH?jv^>Uqv4MGNE`Hr)70i;Tt^p@haWfNazK6)nJud=@oEnoxycpEN3T29aZgOq&3~5S$I9TI%$v3WFF?goss9*DFD%C|Cu4>h59sj*29LQ47WhW$6fpda>Q4iOmOc2$r8|#|R zj!iZ&4aD;CZ)gc9eYTe0{4oYa+ad?78g>cY%d*OYQrNtF#jUIlf9+o}LPX`azUHjw zcVfrxanM{tKCumUc#(jHp3h=-C!_vdw#D+ra_LO&#gcJa2D}WgqA;klg%XcV7?_pn*R*KC$@|Y{D zjV0gTD=1yLqC>Wm(z8lJ52<^t17{ZeBwjLxou4_X z&P0ZJSC5v567!A*IbeEq)!I&Nt$#G!Qxx8@?;Q9IGv?XqxD2h*6%FM^ELi)ysZ0{X3%x7lH^NN-sl` z^yqpE%yQ5ex6=h&QM=~S1NPV-yq(v^uE$p^p{cBst=5Fg@kDh8^w^4My=Pm#MJh7_ zTA*eAp2u->2(6qtSI05!OM4R&wK4LgTojVXVx8?|om@|&5#!6T2k-IvsI|WH@R@=M%$yDA)FJey);?wJUqjWDg!&mpSvufzmg7vWn7&{vyGQ9c6xDN z`ID4i$cvvbN@Uwt{s-HD3=tQ;6wk3F=HPSOV`T!ne%C&}4L;C?QR$gj%1gxwJ z|9w#SJ&gWQ+3$s9pn7>b^!f+vqVUe8lc{rw*sW&vPVODF7q4^Hy7a|BTp#H!m~qvE z-rVmI3Xu>*d#=kQjX4eLzqaQ2sLO5Vkqpk@!DZoXbXZqO6678RAWIn#14@VRR}Z{T z0+-(GX3b}mI@%#2;RfY|y|QLTrSBf{DlaVS80O>Sb&}r0g0D;wN375@A74PTrEK88 z&%wLxH%)kkR`yqzl(Ir*^_j&&rj^~cc0hu!xO;JP*X3gmil`;v4<)wvPxl|;^9u<# zprsnnQIBe7&Ja|RCVk8Tyem&@lE{Nwk@W7q(k%{9DzjUzV_7%frGE=)*4SaoNB2_dX|h}wYUB>KT4%1$)f*mRWZF3 zWPG5m0+vke3J<5l3MI!lrAs*y>3o-;%Kj$U_$$Ttpqjk^A)TZeq{i>tG0V7Sv@l!b zO*KfGbX4Z885hsQfQg96DpFy|XhqG!|0|IPIuu+3q_Wn1$z8&lSdI>m z&e3mj-)_H~zKx)kTtjXKFxw#lB<&t0Ovd$vtWJ>X6~jO2o2ejG6oEoNlYOr+3qy>Q zdTC%H$1M97&TNtDP8N#Y+u4N2N$>zWyLen_IHx`3TW;nv?x8~5d;NF9w`wHj;`=3< zZ5LGblv7vH85e>bJZ(d{)?Ga5hI{$a12@v=F65-(*chO^G#RQW!iK8U2S&uP#xT%Q z_pLN*?Z`W|OjZ~09jeh%!nJdI+e)6KJ^QZx8|)c9+m?OgV9guX{ZoH@e~?(Pk||3204Sbryzihn+TSa3tS6pmU1wuky(T0=jBM&=pj+k2;GSVhOZ6y<+!jBFiuK6baY zK20gJhgy0GMd;~05Bjym>C1>CwaRj9J|ldX8H7&_rc3RdEXd6q(D~=a z+6pE%2Ykx&6^%=Z;b;C{?b7$a)s_UHyWI^&i;2hibTq=xuV8B}y!iG=_X-Xt5L~|F zrLiAim~6+(7xO6t$P5csjG^7RfO-E{h*;4~1|FsGl>lw|UR_H#N%P%rWtgxrF|=o7 zL?cN94B^H!D&`1p1nqe<%pl2Se;S1I8|Z=Uj*^81GYC=c+s2% zcWu{qrlHtS8_>7+?I=&Tt<3TWPGbNjEOGR02qAl{2dRzqgvP%3C(&=X59_#!{%)zb=7Y@ZBSP3r}79 zObK|OrlaBou+j)bCe)SF@x=dqQ(662;=tAWdNg&;lQO$Cy;oahJ64)#*^Ez0(eK&^ zBorPhK*GBt4QP^tt3;nYgbV9{uwk6aI^1)9#sV{wAs&*S3A~HiqMdunItMe` z!9Ft}^%}{{;zhV!U(NUqpxygwaXy)E&8X4M3%O%blxiRY$hPIX`|8TTKD)ZQvHfg0 zGEI+u+jfgLoBaA{%7bWlY9UeVkNKfIbu5k2AAIur#G=&uh?=SWxH?=}m{140`Zapb zZSvvW{xs6=*r2GBznCqs?NnpTwOE?`5f%*g;CsZNv2*OnlHa`lO|w3i*K;d!?eeh+~4f_JFt5tB0pjv&ty6!3*lO4lsnofhrJKqWpPGHm%5fjElYpwXUu4D2PG zSwLU)TR7-OOk72oeZ;;gtr*iM;!(9TGELIHZ5r-7gj*bu-P7q5z;rbE3n5zgF!Uwt z;_|!l=}v80NRdDACo@~Z*7(blS|GPV4CG93J&GOO?W^yJeJ)+24`I6pJ)n5eEHE@A zOR+rvYeYCCmMI^33<+In71E`|r4=+rvV6DZs9stGZxV!l#ENzKB}Macf&$(%IwwDu z0zC#XyvGIuZkSJ;twI3(ui5x-u%_NHl)=Xs+-GOHms(rpyWO*D5>&7@$bSK)O%n=4 z_&-UcUhBuw%H;g!cIzxxOf#c!y`@x`0!-UX+_Uv5#+1}Ua{_dvT`PrBbM*Xf6Xql_$t-=J40K21Ws7EsX9`DXj^TF>a5D~_ z7tK57zA*+hn{bpT9JZ-wL__fSgaTS-MWMn7_Vw3nj7~PEKr!C;KFeP3FfyAGBK#U9 zqu-^%UsHH$TzMv!r$YWq({;ZfNO%IiS<@|IKMi+m1Mxhroh0OG1v{5rCvEyw6rQAV(ne>b#%Gb2TF?qg`|>u@5?VXQ1W3T8q9uk8Eg zNS+e%Y|A62^*RQJ6tcp^Xc=>OnXIedK$AURMxo}SY{;(rr(m!Pk|R*{GkUVEv|#pG zJS5-d7@tp5baceKWLZA#2D0D9*Lgp-V;2N1N^2i9Iqs=li%E&mw@6mAxYt)r)3rco zGMFBNk}v<`{aVlZj&03s*H0c?#JJ->IQXO(=tMH$C{iq#%p!YN z{yz9)#-SK8K9c22s1xOcRi}wh*}3G~3A_AGSS_g+=!y)Ysj&QRq#!-q6p(+nX^_Gr z0RQ{RAjO1$fgEG8)j#<9j!aCQe9i$nOv=9UGdA0mvjr9s=K6W8*ALZ`TI;}ZTdvQv zqT9vmJ<|NjcSHJI^4JXmH{&S+C4fk;6WZ*(Qn!}4m_*!Qbw@9*z?r6Fe^RQE5$ehkiDDh`nP6nDh}if_(dsMoJ@)0Fnat+IB(E&w{dby7 zvWSFa;RH(cOz7>ti+izTZuo$QMs+1euvy)!6@3()254^b>&>DdcDI7pMT&5yO)$49 z=E>maF3NA}t{w0hnM4a-!(*x|#^{9S+$TMWJluTAbNiE>u5k%SYnCvJ7wCQC?f_@p zD4-W@sMmJ(>Yd}+W`Oo+DgQ38f6HC4)V$#K&lEVkV_6SYiv5HJojf-!@@Qp9=RN+4 z_~;X>35TJf892wa2H7r$Y54bmPVf10uEhMm@@d*!&`gYZy;q?^f@;Jnye7#p>GcuW zSGlO;J7aXC# zK*IDcDBj>KIIEsttl}sZpH;T!g1ZW)M4Iv!;q-mOO8}Slr1fyS?Q$Gbz9;2>S)Ix6E=dIf?mf)sQFEi6LbN~ zMBS!-{(Jq>0(^aE)zrznfbhb~I6ySylD7}~?{19&Rv7pRv*{)D4Z5!=4HN|Hv_}cJ zKu4CrWRdE-cZ(`T-VrnhzNRQ^LHr7!n%J!@o>3e zkM8_n07t$%rqXLp#aVhCF`2U7C0Z_8SY)&>@CgG+M2yyIkhrA?)fe7HYzXu-6}V@t z_Fehdo2U+xX0Gd0xfBPC{23BD|8_z&)TUqV(ZuA z!AkZ#Gj`t6^Fv7HZ&L~dJ*3JUnO%WFsv%O&?083#foA4+sDY)pK2S>uIeUq$EKLw1 zduqDx${XFD(XyK4PmcZd#QLBqy!KB@xS;Iu2h}%+cfc9ghR(fwsA}0;$I7BZPPS zGzE?HKA}h7&R_|1v|F+ASY5_HEzIKsM>3K_c70ng6sW{cX^Wg4Rqk^om5QZIc-IhXI&%zJAUu*%*LCvuq?b;{pqhB-;@`K_Z`w@7mu)QI;eYA3#Ol za&FxRInMq`_p9+5dMOgW@z~#E^i!ligQqsu=m>Rmgc2|Lq+WVYuqVmnHr(e~gfSrP z%OEkkmcRFXil8<7v#Vb^{;v!ZJ+C4xUU%l*OQKW6<>wv0s?LeiR=dnT*qNhPO|x0@!4jeZ7-Y4Kd}APZ-v#_71px znO-#E!!(I|-jwzpU4bIo|KI1sgXmA+w4$z84PckLPkz(2gcnv_QwB?uTz}2QJ{czT zTcuxp+AHOyOwYBPyk16!B?+VVx^$cPV!hD|QF{g-y5czVX&$40cI!n@DU=p-_&8WeWKDj=7zwI{Q6-61?q+C`e~>W#DbAdmaLe4(fvF!%?5ZyQ&S zF7?EKG_F#?s1e%2BHpdPQC$QdmXwTehPKCKBW#8XHSy0ki=>r5igmxwFDGmYd&YzK z`>l7bGvDe^8m=!RUAWuytqxNIjOx3#{|el{zbCUUXs$rQmi=BsF<18gQT4GB;G0|@ z8DRJ<1wSOtJHMBuk<6_c#u54CbH)|WNtF1V>*%g#?C1LBP$Qr*1&~XQt4N5vnKljm zq=guTWSR4QHHUl!ZySTSY`iri=j+YPUO>1~?#5 z(q$OgaSqq#yQh}O1uo^Dxu*&lT-VfYWDRgKI@-6IaqDfQV>N6V*}f9XdmP565uG5u z^oobcavbnP`bc@WPTz@sjIT55r$h(!e)qA%-VjiEI{OR~@JQ|RHloXadYS&SV(TZ) zxEII3k<3oE5ZCzeb$DKTwhaQ4rm3NC^E`9*Uq)VkJ%+AGgQ+3D3LV&y9t`r$Q-)$ejSf8=dOqlm=EaQHmx$<|JZ>_)kWf+T1pWZSw$9H%w0S?|;s!^ry zWp})VkxT?g8*m!+yXcMZykHTww>0-R^*35A z1gAR71x~_7zIRg4#?pXmEFy{x^B3C4ET_*F057vBUWtUlt>*J94 z>F3swZvGuTemv&&M6Vcbz_(X!Z z7u^Qs%O|osN2kg`JXuq0HT;`)`fpZKAA3oT7R%QdmDWj}>(8idVFBMMUlvmC-f*PN zN4mQT;*6;G)nKy!hfPsG>8Xv(RHnt=$0NNZ5^EQ9tYW!wa8ZMa2a3CUz5#3mLz_xWWm25!Bk?!BCpxbsc?kB?&0kJ?l3pWUVi+T zsE+N2*D2)VFnU8d{?5Yr71?~uqJ?aIk*xK>XKIB>V}baH#dnS`kVoa*o=Kp42L{Ee z!pa_TuNS(8sY0GQr*dU2f|aIwgmnmOcnSCu%ZJrm%xVs=F(2ghxk5f}?UVMxq}w~9 z8%yHA2C+^2R?&Kr>%tn|U#ba?FaijT=ZCj4L^cEi+^g}tqH|4i(88{Oq}>Ck3hC7a zjdn4;P(Yra@mU|NQj37S{q$L%R2GNBd;ISMX!#8@Ln*0Qx@*@o2JSA&j_c$X@^7HJ zUSUM4Oiqy|1vb7D8tVq*FYYg>G&yiI3t`>AqKlfxV-HMrno#)YK;~4z9-O{^$wWw= z*YktHuAzabl?&uh$_=fW5D`r5@!(B6jnpAN)lYPnGBKNF?Wti$NW_qFfk)EMY=rvP(cY64fMf~JpNj1);gJf(dn{3D{8jwRQ!guZS3KY-Sx~2f!59%<{ z3i~sQfTwDOcI^D}Gyfh??>dyjb(!$bXdLPJ_r9DqNmU;SPwJ)3^%S^PSEJ6_)>agB z!HtGCQ*5>*W}nr*oWhwj1{6wI&v)_nw>jYqx?|$>p0QoXhL3;rZc0DuSp6Y&v;Y{GTCUO*06+6knaMp{u$=hz!?Z#f8WST5`QLTJNDp$ z8*}#h0X+2SKp~=&(g#-XE|3LoKP?u`0 z!$uk9r`sN${Wayqs)W3+uQJr<;;)(E26|d%6yIyhlwvFEI!Au|Ex=J})8D(|4j^TT z_E}c{9ukl0-Ozlja9Qjpq3*6PwE!iE)wd0?O54<0nM-H6XZbwryRsdlPZ>w}orJRt z7T?9UN#U2L3;TO3e<^^`qIr005Ify!1C5v^q0BPrDbS*_xypn-5r4`GH677$wBquz zXl>GFk7JjR4q_*Fu5WU1h5YAd*neLkGn5fj_Ejx7EVBT>Rw}bJUT{H_)4*m+2{@>k>x3k| z<7l}$j*u*t-?(BNAI5uEaT3Nwy`bC@OHqdt$zEQq0WXba4HFwD1@=7Lc>);h45Bg1 z2cV4{DbNZ8B4yZm)#%tn-*H7Y;~2v%r|_j1aN4_PP-$&=iqY_Y8){gd{?jg8Gk~*l z2#k4-rb}GkQ^>CWpiT#p#&Nyfspk@M+U$4*YgGDNx2Bgy-&*y4DCO?5cixX>Ly69* zq@O!R1#wNfw72f_6*Gs$kwF``?C*RW@=$v@wZZafIb~Fu-@5tk_j54vqLh;`N=1$( z4)+w>`|QpC{s5Ea_=&Neee`TWY+(B=!JdlngY0#3KtN*w#d(YZC2_JeAq8V07vJf8 z{w~JWj|}r`euhPqDTWaF1^D}~bokI!$R2Mm0OCw2Gi&l=5*P(y=!k-xPPF}-a6C2k z_=nDzG@I4PP~G1K!qoeD7yQf`F(*skU$ysl@nx9pEcL=**2E)dmDT<)tmyYrttt8c z*GSPs+kcPlWQ=umx+a}3#Ar_`Cco{mj(W_3`W)*K$n$T&^+u_@kirs;?Mz|!Ax>*a z1^iunhB(TM_|oweE7?ytp=Zj%uKf-x2QT)%lQe+>2{Y#bPxfqbt<*7t*c&MVo#9_D zy^B*uBV`1jn|LtKY81{%q5SKEUUuF+)dt01b0fFG8f6#bdM^ELlnc&xHe!9J1*0G9 z6)g`mh@J?C%#K3j#uyrCb1hLUrGOK@F>08nib#eA1f5;UIU)zK6;D32)PZ*6fcH*r z@9*`0d3qR!xRc+UAp%Xo6py`#wC~b$wXjJ29Xqsk&-=rGiLG$+ZXcCgOTUClT*etf zzO?0n#=ZY9rUC~tM%O-w(I<>92#Hv<)KeiD>IZ3VTKxJNUYSP6D5KonN_WWhw`Hl? z^qrC%Wahi}Z4O2wh?AkcE{`~S>B=2^$2ITNyZqr(snWy~yRTtKI5nq;!rPwwUK>h~Ft-+{23 z8+X&xrCm?dtu$LplvaF5HB++~V7w=j-OfAghW9$jB)1F^0=KC-6`5tI66Oep4D)%4(fa&tUlRNf8QZ29g>$CW12cQZoMG_gvbjE)r%xir1*1kZu7_i1|_JC2N1a~lKPfG|Ux&%AJr5qr)Ar_!-Pcq9LRYO(_)P$R|m zoTtmBV}>I*vFy<;Lbu>5kWDK=;?$=jVyS%}8Yj$`BdU3-NJN5l4x z1b14!;!pRox+{#UrdA7hoQulw3KS}u!kn7p(n@jiKUJh9&Pg4i8rXi=gTE`R=)Lao zcM;l6cBg$!#xuhp`4UjuYGcbgg{?GsRl^s2yFSyY%GGrGvIV?Tr|`TGalV7ozvHNj z58AIE1)^t*{OEX-g>1%%w=@R|vZ>kEy)Se9Y>hMue-ATbr7=fmjByZcuy_9Z)x4%E zY)*VO`_I=p`)`^uns?ULS$4|&J-jv9b^={NTs?(oV_oGF^3HkC3;ZE1MDG~d-pAWH zh-1S^A_Wt#j`D-bj1ZaR%#B1v=eD^rK^XS~8HpS7U}S{aC^=253!6NC488UFO2W`+ zYqIHTroVDiC9)d4&5X13uxBU<-I`WK(P@{7GW|RLxR^^{JaqSqi~&ocNz%OLhv-CB z08}EHA=L<2a$}^3R-Tv0WTq`qt3(dITJCuogX0&7xK8l2!MM52Gl{whq;~(NFMa*3 zs~xMs0_Fhvl$90_`wY0wB>1*)d&Rh55p67|oIK7CvQxq2I3=Pt21AGN z-*B%lla0yFsn%XAlzPG3TSrvZXo0Xd`}P50lp@Z0^qvo7fEi$mSSGV$L+3VezKZStcFl{vZT> z!L)DW)sjd5YAzLeBEb=Uc_TgfYqiX;dAjO#5;1~e&}MmB9qdm?za!$!IyweniN<<_ z6Mx_Rck#~%a?a*PJcskN9znFoy;$}uVjL;$EUwuu-k^wxy2hZDG4WJ3YQOwwy_9Iz z&9pU$4fnS@$plmMpn7HMuEyLV>SO3I1pIw+mh(R9E41ES=q;_q`!jPtlM;U$U=VMU zSX7SlXKKNkf2Mg1hqaq$o|9L3WkYH_b#_k}TM23_(%|nW){<*g8t3se2_!neXA)^K zZ$Ef8FD(%=Mon9QsPN1}&+PAa8+~+ayV*pBbM3*4#4y+}v1_I>tspYEvjyX|f4`mdxkPBWCV%_1TA0Qm4x;f6iAhN+$MM5gzeqq# zP{spaG}~H8DpY<55(DPH6Qk9}D`s)3wmxY#pE7a{?-cbuvuOCcT2DYz<|9qe|igvP%48mqCp>>C}i_|@P7yE zg>|oC?luu!yQf3mXQ{M@jx@2y%y4F-QVMbed_Cu#8*f)lh2-&HxdL062;#H;g`LTg zQMMWf(UpIfJCohNd)KOd&r;bAT?(av*i%prUnU)xKAj1=Bj@krkkHvEai;TeP?pd- z`!iX9EvF#M#ox3Npuuh{HoZ8+(#|kRz|uaWdJl(Wn`Bl)0dF$omA#yo-+dAyci(G( z?0;x>A{Q|9h(D)9HOT^{5yQPUO)zO+rT}7KPs{8 zw(pq_FZGHE)uZTAK#5fGCi1&-OqFwJ1jZ-w^Nh}to?ONx%EMHYOB%qZM};rBn)hjf z_7Ky(!yTEdvEY595r64%+o-Nt!@+`75eygisJc8Aa;kMR(_fLrm)~vP^Jy&<|4XSn zGRD439RlAm!Yei6{kewCy`H}pzYrJq6f4y*(-`iQUsfEddt3pmI(mhR#qbZ{R~+KP)u3a z+0(*Ss447)wh||3t<+p$D|g}2&JG3bfVfhm2xfTqTyyId6{ z>z?i4ot5OB-ODo|Q$tg#KTl>n^hMnP-^q^Jc%Smer($SOn^{8MRx*^qu$p_*qy@21 ztli(mM|bhFNT>!l1F|-+GnG`iZE5MT`$~sz#qn9vgb*74J%i*RY|eZ@9P^C&_AvH| zkTevy=yXLGt}`@LBB8`^gqsv>6uJOIlZ*8>NfG`{6Hc#iJim*t$E;Sj$tJuz=2gt^ z-SlGH246s3>5XR?bB;^|PLYI%s=cO3m^yY{Q}1ReWUvZx3X=@tgpG}Ww{|zPo+hdC z(U5HXUA&hw^ZTSQHhd;&ql7lG_-*92Wrj2Tt0np_15IF+;bq+3U$7+r8ZDxec}RQ= zV1g129V>s`sQct;A9vURLjBf<%k#{X!Cg5)X6{K|U3pJmGb zG;;9pFCU3}TfSIH%RJa}@Sl#T=d-729ICmg#QAPnX6|F6%mY=v4)H5X*%sBsWT20~ zA%>LCuwJ~@<3$>y(T z6d7eQUt6t3{Jv9mKUKNBuYV8CWU*fS4`*f1EGQ&$sXd`O_iH5COv!>f9LW8U8g0_l zm+A0*+%PVGpYz`{)j|%$C=#qAx;}ngXON2-^?VD8ZJE01XdGo*|DisoGBi0+ws-QK zQTFNCaH%B?~c5vpUD#bOo>bZ{z?82 zEVg!e6ff`Z!yYdSMzyyJ=GMH4%p2|~wEG0h%EK^fUu|mFIq>zbU=;7VbQPr3<^m)jsRi~NBYFAu%+Ik>30$SjZ|aGP}Ey&zd|sJYq3WejFn>kyY|8K zl{N@#ju9z?=XBm5XY4f(!21)6sXP}i?kQR)21Na5NeIi zf2uPuv|hd^2>^RbDp^5Cx!3+;_sIuAfYOWFI_DKyV zQ>PcLHZ#C<9`5`ueunv1H9%q(r1EX?=O4S-MRGGkpz!?{j)(Mq1C!aipoCkJCE z+&!8K@i~|46jYe7f|Dk%k~Gm7RczZ3a+f$ULeXcdVn7EW^ClE%bVxRaO`;}f!o0R4 z02H_I}^Oz1kt#t@Jvv9yo^ z&5EHFf5Uh6qb6F59cJ!-KN5l3?;Q})F@hXKg`zzWr~aGBXrf^s>{d-)1jlplI%PF%_Imd-6ULEQ`h-mjmh`3GxHy@pmRBRhwm{NOxdw}i*HGizi^Q`w z|4x?al&>L1sBQLMNA@-+U6XZOeWWtD7P;`OnJ(fH>iw_p&C)2Vm(){OYZyFGVRmPa1 zp(*1oup&iIPw|0bezE7jrOfj+RJzdy-sYn=TYAF7p7dKZ8S+}6d{&;`g}zPuLx ztjES|X#>b{bF}nTg9g@ygaTgM*D-lf!i4l9O{^95!XZhP3!PZbvaa?1J$bc87+2le z^NiW@yYyO7UQ9j}+pnn{ebZ$bt;cZn-jaCu%B(ih&#Vq~-qI^`rz0A=}Opb98zti*sUE?)BfHIJ(uNtWlQnInsruL;sVh+`Zx(JS=-w zv}b&_O%{Ujq`GLAQ?Q zJrXJExJs&1+svhZz#@CgrCuks@Lf|uK-@U6nL&wSW?cL-V-%FEw>R3ozlMW7NL-*z z@!Q;s#W*(a6knG!1|VQd4w{PLWvy|$uKCUraoY?w-7A|M%#P-SWC=ll=`th4xX12r-a)Mz|Uagt~7VCD++{F+Sbe zj#OZ?>GFDiFgo!Fwn=17d&8Z*GT{`w{eCl)RAla|*wKbE5`K_eq?MOz@pyZ$ONdny z!0*z(;FiC#+W-S;Ie9`a84iz4kWBK=)gOFwC)^+O+T_w8B-Vx(PYydw3I>vEL|Rhp zSO`3#iPeuh5)U=W)^_PN?9{DOJ3r!n$tn4I&TOmU#l*%w!sj*p-;jC&k&Utb+E<(B z!4qD-hjSY-RaL#Bms|d%U>F^jDVj1t&9!ZFJ0A%a)HU~%h1RFMXBM*~m@+@(2wL|E zi`=bIiYJo!_``9tnSiZYSRcP>z(`c~QhMG}*oO|a-!+X8?aQP=^xENxG)Ee+zhR$g zsEucF2~>K3uqMiL6TAVxC^>N2dlHn&GE?`wq8%;~k_6KA-Tzg(rq5Yl#6<0hw|Gw@ z6y%uuU48@e>v{8?eItT+MEsC8s>kFw{8a{2^->E5xkVUY8NGExSAtXDE9Up~lXIq% z%s0Dx=RtT2Y3ePzjcwT^#H2!FzYzSLQAVF8gJxw~>ce91?_kvU-oI-f_V$y8NtHK5 zcI+c$*5~=Qqy7|ca2A(i8*d8On#G;Ha~%>tb#rzVL0Cq?#eX-zS)koo7X@_7=E@Q_ zZ(Nc_FCXi~a7*D_q#bxWXiM1_V0D6guD=p&|JqXKTlJ07`kaQI)TW4i5*Z z>ExCz#cYDtTyW#$=SKm3ONm%nSg7g&C}?S~=>e~Be;t{3(K9EX`QI(1$hzZlz3d-5 zy%$V)f(4P9c7tp9>#r1uSY%|8`*3&CeW9p#@PDmjo3|c7WvS=g{D{=I+w5i`Mq zI70O?a@yZvq>8Q;)4Y!b?TU7dgQ&&P>BaN^JAna!?$0Z|jRxc(eKLi8DxHG5~yV06)yYG!MCp>1y<{)Neqg$(&HmWZ(HS-%QEv^VuaI zl7sowOG(n~yp!0-q$132?@(%>v40Cpnv_odiIZ1RoL2Wc*SdH^L>O6t#kl<)k%UF( z4B{Kt5`0nadRu;;Gjh{lR_n+GsJ{(Cn4FW(JZLT3GuG$(YpisD z=X@Pok{8WQO7J^Z@d1$kw6rEnK7OBleh__~8%GED-sA1M5&{d#I%*geVDo(fYUBa^ zm(Tf@(YL?<;s8=|^7RGfZ6%Ge#aaPiw|;W9Tf2_#vb_`ku1VbD6FMCJ%1Ea#-v{v# z+yFrB1#n%_wY|U7rc=BSBrUK&gb#fwsgQqQ9Vx9<(NfiIWcW>feKBXg+IePKE;AP| zThFztz7Qf1Li&FEy?4o;$8a+AO#~|xdem6t|CCDG=Yx}_tv-k!ar24)^}4?gf9$z= z2Ru6(n+i$w#!(u}P-kAlF5=nUbaLn^9tv8+Vmew$i&*~aHMX!09rS7$2`=%#8vQ=i z=ENq`^tqd@i%;djmj`ed2obEh$2hybtNoG`9W!^$`M?!ztw{M_+nVkaGgZlD4^`uY zv>1bvYAUefbBsvuG(;c9#SK;&ui;rIGN7rgIfgp;_*Q=SmZNkFEvp zP>z%$(n#a>N}(c4JcZ)r3kF4OL11xpA_8$=O*jlGKacMw84021&^)O7cGNi`1y#p7ejm}+^h1yXGwdfA@qS7?f%^NH4G0XYJ0B&$Pi?9^Jpz z)EHFuz8cmd62|4M^xf+Ek^ZZVy=AzF)iG>N1E zbb}kcaIn}=%{ln@pCx{mBoI->&fDhp<;{MIaAzwCg?ajLtxaRBh06GZm)mfS2L4Yz z`VL++3d`O(e$At)HvIcyS2U=5A25bpitKgO$j?sCdd$AFPdYp~kA-$dmt)pBb$wQx zLy^b#@YE0+?Rz6tOz+m2+a?`2xUHtj&S-chb5;OjV#|09F8Lco>x zKl|@eM_>u_XQRyAQ_!Y2D#@SC=I9S?Y;Gws_VfQ`wzb{Mu6)cQGMt-sFGk@`Nks1a zev1uLqzFAn|8IS>^axyBcv(F^q&L_FZDAFPF!wDGl2OF>j!cmy;iP+XnXCjW3|f|T zDIRS9m@g&W@KArlq!Mz`bok)<8OUGp)C_kYfXimYE^7HUjAp=B<{Fa839?RS9gfT= z^mlt8gXQC69g;T(Kt~8Vh!B6Em)B658FYp zYcpil<7e#L?SzE~chItG9r={^BCx%BCIj9Pwj{=X zKU93rA;3Xz@I&V<-}SAd1^s*K&FhPmXkTGFu}jok&o9c#ipzGA$Ud7jkl)_NyS^Fd z>{*+6ISfXHgof-9MIu>h3TF1(~PNr}d@c5l{OK#_r{DWX7apIZf%Fpr7^cElLb?%%QZSVhdI>AnZ~8JUj^;o zmEh=2dw3)rZ_PNtTG&%*`_*lOh+z0|p1HWJ@FgRQd z0dG8{degMJ#U1q zYA0ewFU+rPX$fODdbK_$Djgs5A=JDWlJg$8u@Qw3-m8XJyCHo7SSHz$fhBa*p6zmY8OEGC>t;6#e%`!>4FY5Q-lJiH*n+@eO!oxKwwwXCLvZPc^lOcTD2d3m<|6cI9B|E*EavVrm)j0dMq~<_&{q zLd`^i8pmA$biX5`;AAiT+#BEJPG*yoZX7Ixjq&xU?|FD_A?87F$DL*)$2QVcrn4#^ z1fsUFf{^(RtixNi_Wjf8crN=HkrsNiCzW8$Eg|k-Jx%%^T=;ILc}1x=XHG3t_!_Sy zNOULIe`X~a_UM@D81__V`Hj(*09qC$r0yMvh1PfiX0YG7fS2{#Jqc9a6zSqA;HB6L3kn=F3#MBxErSctv@MSsUD-kKKC* z7`}alIsN(uOWcM6P%w&#^(BJy5R|N z*2KI5938?6+nU|K@^-8~7yBQCn`wGg(Vi#?O}y+y(=aud7*XAI(f#>ke4Pu)3{vm&y*J;rA-ChpZIFbOyTzNZ zVQczI8%?b6K6}j`4i8ZGpeeyiB~_~I`x!IoSo&ra>;5wvG$&m81^==Tx85VPUEW~L z8b1}=+6Qh*vJXhVrCn0IXM9#pzrz6^oO^Pt?A6<5QDXl6o*rh+&NvV}MOTw4$I3FX zn&de=d5{%k8vj?-G(7#+S$JL<5hrHKdB|&|0sPX-wviA&w#%L3TOU(5>NU~L4MPIN z0$`7*MqUPw(>2NJ&4x{sEn%w0A?xX^3Gi_rzbDUe$T7-lbbp~(AB)*rtYgR*NbiNijBYL#iZo@*=JESg_xEhg*J5;zDvHlU zV)FB*-tVAxoBVxr-q2RV^EJDUzxR%1Z7Ud~<5x3wd`>0Yd_V*`;}2lNDx3k>zTJ;_ zTn8L_k~A!>9JX~JUh!&(dY0M7G7o=G*|YSN6|7Y)@D82sw7?}V3*p$ZS1E0hZxS?j z-;p{jr|Z1hg0g84*I)jQ$nip32~PSw<;BaI*gh^|OASj{ljrMq6Z?PmJ}Jt-uk;@Z zP?&)?yV00gWWI>fA|by%s~Y1HTXqRhKIk0&`7GCaG&_cbDV4!E1{D34VPNm_HiyJV zNOQeqEEr?SUr%K!Lj3WV3E0W3lPg$eig_2gm@XJ$?sNWcjs8H)?|OwRZWmXKW>8V+ z2=6m8+&Ureh09cc>ZuGgC|hP@k=*X@hQ2|v+60}25km&n9*BoUhcMkeKfEMLEM-r= z2xa~Y$qhvna)aWi2|@FCpqk$Y-+=UKzQ#3b$k53kT4QGcUB^RC{d^w*2k#k?Lx;a1 zV9ZD>M}+~txnkFvA1Hn}hU1K3UEzP0?jfk@>=oq5YD?_Sk3|F8NYqadduBEHd&U8h zRjpQ}-=%+iyQsV&zblhHaF_rOP2E@ao3Z^pB+F!n{2k6a=tzR8B=&4)FFO)5ZZdV6 zlHN=^_4$;dh~?&%f8Sn`>I+5(>8G)ddz*MST_5$#-HwQIfr!8({*@bJm{%Pi#x6Rv z5$LLW&wQTOGhnTeh=&u}W-~(tK&_X$AeXnStXMKzK#+Nr=;G0g#BCa6Gg*Oh{AWdL#2hY-=-{kN5%+N*=*+=GTv#^mgVjKVxWjb>BWs= zr}0rS^CT}ozgb^y*mL=I&Y59^ z15$G1Q-AR3Ed=J-g8p-kT@I@DSwj%S$y1)J!C1RH8{v4F!W%n7w9(#8NDG08&pWSK z`oQz2kqY*cAjxYB7i>Z!9kD!MOtbIcO#W`Zzr(J$%(U_k?f{M$*m0sfInCUw7dnzB zg3Lk1Dx36acDS=2@G?8TZg+bxTS~j5xOjbxoj=*}HfUdVO{G=Z9ZZO?5$L+^z^oVj zpuOR-v{)czl6bE?%O8VcwI?SGpLAhsZ z0@vRX9BmEsUPBe2@zc{D+suv#rONKG>@hBu@f-0kmsnMk5eN;O9Yx%&a!w}76^(0# zQog2^gQQIxx1tF;L<6c|v1S$fdnnm!`Ca=yI}%iNwYzS+8u2Ov$@1#&^zctWHr+*( zh|K#hLWN0!GSV?^jmO&0V8QM(Xt}}kz4;WbzqiKmzrxJ564<*F2E@R--oDld1Ixw2I42EJN)_^#QgQJyIWrle@$EHCGo^tzQUXt!=X-Z4}VJZv$ke#3O0 zt4yTE0;955IUB&1zufpai*i@On%pRLGVt!4;_SK`KZaYOkh+%HV z+dKnbQ^#f`ImZSwY5oLvchCD~Y)Ia!2!~a(CNC?rxrVX{;lZPRG=Q)+ac;WzWatDMG?SEeFnDBMOSi^)p> zF>dkeJ@CtT=Zbp5+M>K%zb`b8?lRH8Yo@Sg@F8bI=la{e42htTu*9}I8Lh;F zQ&fxZ&qu;zn2sKSPoZ3p^V)KZ7C(-y`)~aGuYJl@qkH~7?Pfbg!qW77AMYMr$EGO# z@x8Yfv)^?m8cae0vZmMR8%vMTNd6zZQJX>4R+a}WNW0Lw4LuOqx7xFU=V5#bbx=KGiS{2s29vx;f0ob{l5M4+X7c*DAdLZL^ok| ztmYX@rGtBt)q5+9X$}vvS~SX8laQQ3h(zfZE@E z5;j2&mfzI^lcT-*?tZkpeHp1sP7XIP^+uB9l$@y8_MCG#mhicwL$71pzl%|n%JFQ^ z|JQfv^1p0-%Ngc{^!oc}8#6K@6QTZneWRThlL8fX3Tc{`sXKJfx`b}f=eYAqtbw35 zUl({#ulc*-*?%c`*=#PR$)n@)Y1=N$_pe5LXY8*wG>VZ8`ztt>aB`Xkb>C-Z_&c9E z-wQzE@_N4Nh{dUPRed;NIO5lj63+GkG#XKgD%=M{OV>$Is`|QS6nsQBSbJz17L$}% zlNJT1vhnxR4X?@92c&l6A3bF%WDPu%Fq7x2e$Ma0hXQ9^UP9D0`J4u8>;YRt_?bkK z2Pste_MUH-QgX=IROP=|YibkHo%Kwnc#2E>Yx#~cXiIj@fuj&L-wQSDUD`YhuJ6YO zv=LiFU`z<{ce(Re6oszo_zcBPOmYtUyNL&gle!dNx(xHhNA{Lzxpc9V-~uC!?2c!O znh@D(S%yr@>L#ZGWaz#|NvlGYNlZ}p6YAWwIA>v`3grE}*nGHFA!nxiZ3;p%vyq7op(0B^4}+`=qURHl*lY}7O=#KC5^s@yA{@3Y9X=V%S58?q!c2` zdsVs3PoC^!ne^NMNkF#0mgb9%GL6*}=r}I~d8Vh`*YK~{J|p<+5ua~Axv%rDh&E-5 z6zS100mNG_PJHFzN7&!c9N;J&^IC9-7zVx5?dtH9hzggty}L1e!H#LRn)X}%w8^+< z#_q-F6{Jdhx8H$wWfr1s)jJ=*4>yFdQMBrq%I6%MpM~50rcpbeVP8^KFc4*~o|UWY zaOT)^4%$eRWO!#&1i3LmQ+cPishuFC^j#aiP(nN6N8TzDZ?2=?Q-&1xSI#-1_WO65 zH9*?FRA3{8TB9{S%u`HQKXNzbTU&cAO*nuQ5C;kln~;0nL=_e(&a`@Dx*V{_HL@gD z5jtc5ytS|B3`Q$LHHH39t;%Er3WLJKiAWuY3CuSxw?G z_H?K}8ugy-^eB0P_S!%o*7(aVve{XO6fOf~$-sjl{Z6ioy$ldk7@zpzPmT}MEpnR? zyc*u9%bqd+Wxm?>8S?aJd(r>BJdzX7jk`hYoMcj-p!ExM+PBF-{=0#j*4|zq-3nr8 zDz!Y-fTxH^h<49Sr!R_rT`2vZy+0AK`|E|Cjz5FshR_&-f0&n=w%C>J4IKG9z^#VOd zJFypKQS|a`kn%R!8~%NoUvRNGoXigT-tJoGfMuX7-Pd6X5gX21rO(xKvoOf%6Uv#` z@PkIyshD5Q{0xF`$DSo+Thu$KqQe8{^SkzaYBOnbDiAxu(cxUBmOy6g6O06~&j)Sq zJ;B)Ys<5d?)_Bw?QbK&Jv5*A20JBhuHKRJz5_d4jre8=-TD4v+ZXD&#-*@TTJly?b z_w~2&E-Pt|&xE%Ji`k8l;eL&L=LL2Sf8r7|aSU&sf|vZn;w>?@QGpKG?EaWkNwGl< z8rS0(u(@43f~FbaEbX5fF9xG$9^&U2m9U-E_WYupB`H!)5zRP)zUysdPEk*DSg*E! z7d$SEy|X1nhrU>+ZPGI+mJKXGczENu^bSq&)ie2;s9g$iuxiyer^Vd7?>j%PPyF>) zqP@%8xAOiUF~iYkK^?jNx`jd#Y|++W2KLBb+UEEZ^JC26hS_0t=^&d>RhOCyTvet8 z>@?Diry95z11d3;M6|Y*J zK!GpN6>FJ9zY-0w@b7X|`QH4$tDX3$!l$I6zcUy;Qf%I7I;V&@$62{2-@fKvK#|^) z9=)G0#}sj|p>7CidkUcBZTJzJ4`~XrTehP5`f%0Fd70OLBP2&ti{dY{%!o7`< z4es9ttmS#}^R4N)nlqT(@=XgMX;a0+yvFkrnb5vY=(<&A8%5^?W;&D>M`cHLu^X4w zpRrHVhy(uBAd?X5-;Hamd{J=|X#Sfa4BrLl)%uEpYRWIcP8eMKvrNl(6vTYY4w7rYs}J1seba^%xhp+Biws?k z67CLcfr0;B{l`ip$;@l@;dL#98LJr|k1x3hQ_}V@v~Bf(a{JD5mY6MU7S)z9-q@a# z9UJ2i$+S6mQp{k~c>kq(KGV|m|BWDlv43Y|<(({t$#XBxOTqQ`8@`LrlHPkXo6S4n z)o1;rM^e>OU#EljUmUn;l?W{3xhGy48opY`*X6kC{ZRRR!c9^_7CXHsApb^&XojR2 zJ-%vGfh@xGuL;@q!5d&hnfcUhx)d$XBSvRf2o5)G$lnv`o9T=^8gRe>UE>8espo&A z%5yS(?`{Dh)^Ts3z3*Zt(E{bk&2)WBsM6mboGhmEQ6h^GxFYZl5`-jkWkxi}N{P!V ziIIUtcprnq%6ZDiag9Y{tEJlB&W5ZTy>hJ$bXv!+(s+BZxDV*rKC0`!&miI{jylWx zc=yGAa~S;FoQ^V1!5#PkBfE zVy%6OQWLQU{(ZPhzu5=LKmPl4CmGvYGIMFjJ4M7(RtE0J{*1&EC;|MoY@CXHq-PUB z?Nw{2SN7&#HA1rdEsX4!m&P9J=$x}O!y~~X%)AzroY34mvWzMQMUbPXnfOHflHUk# zl|S(9srTwy(nq_MBRIl{PlbP&sFI0RumN64#uZW+3REu4OMGeWHa=$bg}3{10uDHv z*#(l*^oSdUSyj&X*qOgs0}#p>h;0P`h!+pcEx6(>BCi}EYQq!Y>w5~1p>_MF$UOcI zp*;3R{D_!OyXtQK7^7LbF@OInjhcqlhPwu!r`hn~prX2;xv>vBhSKyV`$>l2k)+wf z`X!TiR~3Ez?FPL4y<0_DlSiaIv)9poo@~V5i9H?Q(JbRcC?C#Q=~Eh}I<3hhA`CRP zv>QH$4P`-fX9;^JvSxPVm|sTYHG#OBx+p;IT7Y8@0GhFQ|L(3;nCkJAZ2WDu^1Snr z=&65KTeugfqCikizpvGz*Nsd+@f1oHJb4(xn zmQgh^b-(HwwBE#=;r_4j`L4H@;le7({q&^^S;C*Gy%;3_D{hV$(fOyD{l+rSn&8SC z{~2NVE~5xO8Xh)fv%C6HGMyh;5w~zNPuuoS*SbZ}Hur%~`rR0IM+e<3g~V5`7O??V zqH@tLjPYoAZ^*>>Y|o6nqVyK<(G!-8qHr&wyO|ESg-w3wXZvs`bhi-U^Fu5}8E^P7 z!-|Ultr(0M3!*jd^-vLb!_cm8ccCaDW%9rXdvZZojZ+k>mE?Eyd zJlEXns;Gc^g@nxWgg73&_x|FU*e~rKnv&+bsaS`=DDnL_Q#{idcS?K4zqd;e2@=JU zIwiZ6w6Wvh?im_A<<(AfoqU*gwCYELr~XD8KAx(&z^XP8j5eK={6tP9{qr`3-yZnz z9st5fjY8G@on7N$J;%$u6;RSM7j|r>QRnsVbZbFu1Xf4#rYQcqa!Sxp5n7jyl-qmj zLUo*NDOuirOdidV4F0f(c3d#`45}KaXb+Qg56)WVfpt6v!m^E*4n8%@&T;(eIpg0Y z>Gu{1{kzx%L*5x`Z(_S%;wKB~QG&D;R3`+?jstC@Set$}izsmSzJ94+n8bUx%RTi3 zJQ82rd(FLjFdAYQoUEN*L!raXhE3CiD7+uO#+ zcZ4!Dmy@ zfvCSU`Gi4zCU(_x>Q)JEspF+HUK6aX6)GSA%HzfT(~1!^ci30}=RF-xT*MmFk>mFs z0nYu_5ze3AhucWB^ac5Q+jrKTl2uH81$WO_Z=KvUGN0$3e+J?c#fe?V*`9q z;nsa`&m5$Y2>weKd*`b>-0bUYmL!nuDXFgC-p}1CP6WHdVewt=Bn)xv-JPPxK)LBe ze3q^CpWZgHC&N!v_p^4A9v>;qC$wdu3#SV(#Ynzl)kDi7+9X!Mbw{c_QSPr%=$cP| z6vThAJeiO?Dgo^O9T|Pim+V`&=&?^^!{5L1##<}Vw3m&|It>|j?5v5k`D%Tw`-E!$ zy$K|Y>|TCjvbMf@lS%t;jlA|Wmfp89tS(EJW9}(o>nCF1zMz3|b3WJazP+aavQHNI z6}{2L{yhRmEeMB2qZ-vWU(@r>RL^p8ONa6IZ{_j}@q9Nc7cA0I+Y=EhW^8k1W4a~% zceZa$uXq5bZ3g)>NYT>|(c%3}e)~Df#K+PNW#+Tj+V6As^Wlvjfbm|BagX3{9-$!i zq$^JrPm07d@bOkw1)mNR{88N2krzu1#LOLu7NvZg~_ohpfrUL?8fn zb2HVeb>eo@%TM7wxg}yhkR$40;&3)>7k7WwLte6k8^~)vjjAkE7g_`a*teS4Ucp^q z#-L)t$L8yBh*Y=U?b9lhpn}HvZ=&72xA~zBy5{2JrD)=O-s#OXLkX4=VRuUo#EBZE zqT-TTf6Z#6GWr7}y-rX^B;vc5WU_Zog482m**Xt3@2Xz-zGrigdObPL~ zH`vLLst-`{^B4;lG91&=bC0wdi*KktiUVLDsL0We7H{c9%tflVAnp1J8EwXL{q<;a zM<`(~-2Wvvgcp=YIwPMAxcAc#vj1t~Guxmeg{}BG-f%Y-Ja5k~kt2>k2?7nfmx030 z(JJ=bXE=>{m-d28bIiWm;C;1nDbiJH@8!CdB*2JE5<4s1L{_H!JfllDkoy!2x7!s` z;y^%wGC#GZ`;pi%t5V@jU4C?hJp=5~{miFr^r;28zH0$P)){Z{Pe!2rgE6Ey+J7}h z#u}A~T13<3Zm2mFhXetX679BIHfUqbt2Ue6q2lkFG;S+3km{K+?x|xTGWvWv|D6MD z0VX!EEg)%PmdAdv8eaVg9*>$kAhU(nydr`T+0a1ll4GB97BA9CS)@jVacX;QB%8dM=9ru)^mBY>-TVIl+M>&@8izbGR(^JeGFv5!~$fi zUn@B-r3|^79Jt?e{PM2X*t+=A$uV~1;f-=@SWssp#t&2CIq{d8!M^{Sx8Y5t!O?}N z)9o$_$oRDK$9Hzj%+0e_fx5+4pbpq&D1MfbdlrJLy~ghMqwBmTF~Hg9hlKO{-S}x( zK*v$}C|pKUWY#8uNnfE$p9DZ6H|<~R2hM_mOcN$r8N^NU4e=^_U4pG+-giIOR%`{q zsn`lFe3r4HY>ItLz^Wqo^C-L*IMY=&a*GjAbKmGgU4xQzgOqpwscfDtqw5U1R6`&Z zlhC52L*d{XJl{N#GbuKFPHJ`0o%z6H-!(*(`z!7UKr@mGEi+4<#f_2JeqO(#*8T<5xrV`8Y6 z^FRqic{o+6a7%CQqqYBxsK6xrG3)y=_m)ncOH8Xjb-yc%RG8QFeKHb5AA?iy=Uy)@ zX;&($?VLDuRtF*xuF*MI&`$Sd5>Ip!l@y-=R}F0QdpqR^ZXz>}AJ@-HYkRooMnE5v1Xw1-I~%Ua zTDKC^DpXJ4A}P@rO<7r8{5|Rb#cRV_$Rrb-{RbZTP{)rtn7I9iUfQlveFD-fZR<~& zPJ6_2y;G;3`t+9Tvsl?z+PoSz?OUhX`ekQ!+BLM}Y@QeM%#y!k9d`q50qDl(Ri?D2 z!@blz2yXqB{PgPnqP72eewSOTdq-KvS1R?Oc_jmy^-aUC3g{;0(mPxD;ofRwW(qx+G54jOA=)+{XKy0$3fFSU2EE zznXs<3mcIy0H$`E9i7Z-9x_sq<(o7LhUjwqj6D36M2W7rfAutQT%H z{8G81{XY%m?IxAYh8HBNHny_tMz<4bGZ6+ zCZU%W4t3mYrmaZX$ z@aNZFHZPOLDr~hkQ)Pgv!DaVmzQn1tiUpYuvGue=Vc&JNhh~^*W%I`OawR3}QIj#N zfWFW1*>EKCmmKDa>RNQ{frSt)@8XuR+W4sh(^wlA=716q zw_0@4YLd4`NBrx<`O6HZM-0~YVw?1tB|CkH4r`O!Tcwn@oD<`+P!~A0Z}62Jmzwty z+`|7llZ&=p!07`>1cWF>ZhE5U3pZl4o}?XUYJ~v}!2O!3oVEC#xzm=4 zuv&bC!&#b(p+|;zw4rgb=&&e|6MG&b11omyoR{a0kT8+uuQatY z0@eR)&row95#x^>F`SGICR~2W_Lb!D=R|}Lv*j~dA`~oHtTqUUgT~3oc#Raa6`am= z+0IulcTXSFdSqoK)?1TKu=w(#>3~gQ$8$`Q_hl*b`J{$M?a;*?XUAT`mZR&@Q8vQy z5lW5Iw35)r5bpI!lGTfV3xWGDPvvKaGsY6;=JqXrD_LVb{6cv5yG}!YhYS*b5!z!< zPhp<<{clNOxyMjpSrW-JWctp zOe2}N@@U!+-iytohR@}^w`Ojd)4Q>(TWKy=Z+#0q30`H%Zw)d;)37n)il1PTX9)kk_ksNJeNfVZH=iVxd7M|CnWFP+l(^Yx zYL8~Mm;!8Vs~RgZ3lA(mBb}7CHjfVt**Wzv3!%`glTT|D7hux1l{Ds!8*lp<_FPrx zKczA3B!#@U_b*13Y=>AEWTgY*$0A#lrIhG*6Oq(uqgdiS|8DKHRzDd_95ct#06?+@ zMf^S)v$K95;9GUNu_OGGoyla;RECni)nG*+5-zJnKi%^pbVy*%&M*dgEyXx%9S}Ds zYP^Nj`2HB^jW%*BbMVg+=B`Mp@b!P})Gbr^SuaLRZ^Xk29YeG-5o)X_*k-f*coElE zvHd+U>}gaI_ks4otAHu)lQ$2C#He}9fDRf=Du!kXqspYZe@k~yI1cFNhXXb}c#a_V z$DeO9mB)FHV~M|SzVibfELWZQoyjO5in~d|4XN5!=qnFp1k*x=!;|EGvXiP+sdluP zBv0leOd+78d^4GEJpk;N*bdL<*9oFe2YbRjX3u*scMd@rS{uLM} zd4@D2Mwj2+owNE1qGID&I?k_YO^=BG(@U|G5+=iI+ye&r48mLb7HG!7gG%`S#QwNi zDVqa3aum!R5;f^rw6;n4aPN`u6};8(|G=ZVdW+>F)V{oN+;^vmwB@X{=6^ z!l#3^A;1|BUk>;)y)3GC`iOHK#1r_%zLj(^h=24vwy7n#t8d7*NN59gmE-;{`wg7$ zR3m(>aNj>&^s>6pq=~HC;strVoxjyB-^eim(|c#39RB#tWxfX>_=YqeTqAj zb~p<;DgkD)6UWY;4`o!h9(^S2M-Q@}`DBVPc{pBT{ilgDyUbrLmomOH{HaSmwcNf8 z<4e7%p0_k5&@!Z3wQC&4dvM6W%+ESk&*4MQ_{q}Ibl$&yx7nTv^AO*SsFv`TqtqZB zw!%Uh?vuHggXK)O_Ty7*d_r_t^gG|1_<1k(Gdmc3mwIp+L4BDkW&zA9&H2c(>jd*O zzQRHbn4sGQqa20LX}5?BIs+$ZZWkf!ST{t#G|)Nf9P`l6z->Dx5Ltj2e0#g&t4+d zhku&s_81vWCR3~Pp3;++G{?Q8(m9P(Bf7nA{!Agsj?6Qp51vC2f9i?GS>N9p?K%o$ zp+g^(r?IYb@N7JPzSlnIJX#d;h`sDQn3ThNVV2cNuvVrll<&dzyxIm2R-BHx4B3^h zCFiKJqaIk!>2{pvX%A=3muqvB*`T$hY|CLFLi>m_`74qwpja5$vVM1Z+6slua^nVt znC;e=ZMMA)B8-yfDx#f(s{=nt10mj41D5yyOf)3+!ch8qXCd$Qf-W2#&lneGmb3RS z$7CHBK<8YNkN5RQnZpsR(F@+c%k2|?W(sLWbbba@kN~2X?$4l%H zaLY0UuZ~7}tKv!~4uOo&Abbfv()>wIqPxDnQ#*RjhC_rL=iRw*Zz404&aKKfcKmE5f zkA@@kq`rn=w-#rjWZ5OL2oh_RFU%pEuDtO`sY|+80D}KaP9%c)2puNe%&`2%i(*OW zIoF?cbSMJJGmLmZ8ZsoWzw2ec+zP2EU;e$`wm~oJw|Jzn+t+EsQ9mejq!-jg)sTyL zbZsNRPp_#2{W)N_6#jmBb#W~jkFYw;do`z>=tWno(0!D-QMAbno94Xp(W|~d>+=2^ zDD0mpA7YV|9{zN-2Prxx>z#eG(V1i3qqTKU#yGZEmE~24N;5~YgsaqL-e;rey9{LZ z{wo&Y<~tz}ImvkLvF)R`#WnwNaS)}9tDS7ftvCoCocnpLBUUrX=1|$YYa2IU zdxt%ld}!-qjv1pQGyKZWSi}W|5eb@8ER7LZdg5MTMV~3BskH6OD{jgD`T_QudF6^c zYxJFK{kfg)_H4n69XpS_qH2|EJ|S@hpqkXvPcxk})3f7|hrHr{?`7g?B*uLovD%}i zy5kh(z5&@&ivQZ21Xb3o+*fNvvgdD5_hNcU@tJ zF}h~1#`o$w@P*yc&2X!$E+;Zq)Y~(|3^odX&FuDjiEXgFk^HNqmf@WySw6g?7N2f6 zRf6MzHDPok#%=zOT~6=W!RzrAq#GZq&;RRD)?4P7i~sdz>#$KO`C4qlTpOv<=f z!^mmPbwAnNo&i2eIo2W!&GW_(t50LhA4z4A_xV|i`WwK!?!>lMF)#T@uMwE_h~po}~@?GS!<$_*kZd3rsR;7YZ znO0~mbg8u_Bj%ae*uYJAz>$durQpgXJ$82qqm(t4Vgi5JI-a8i@BgB?v!Yyl5>IB- z?*Z(6P}YHK3XXSxG4rdI{*?M2x#^7p7ZRHR&k<-EmISEUkW3Dedx~5kMwQTS&YgWoH-BC8onC6qkC(*S zb|(;Hzb;DoDvBY2$mR5~4n<3)uggeUcg+;{uU6h135g{iPxE`VLxA>Iuump@tSJH| zt6>l4Kqb#blw!>eH)?O$Yn*qs9fB=I(_SB6Y38NVb7##1PS0zO6Q#vqE7^}B4B*UZ zU#AwNq;>>}N6pUcTvA@Wt@=ue`EHEsc={acIBFiuwHE7-Qfz;<*i)K(3M;>GJ=OPAOlO)7z5aD}Um#T`GGyVJ zwhog|VKGZ3NEujx?WqA_F0%z+DVjf{hoC3}=X-R@Berfr0IfN#kc}$E8GKS^KEL7L zUS7571Jm()rBy=DfQOwJT5b$E436R;q%4#%>8vVWXiK3=cIrtYxVXEku`0h8OlrFi z9EygHjsGe7vSEa|1;DtJ?Pv7$X?rwXuU})%`S z?R=IXQPNxo*yzO$tB|Ta2urYE!^r2(3?P*(WWY`g@4ar0ZmB>pnCJ;`#CH=J-yJGl zYSU*q=RG1!Q_OorXS$XG$+Y;l0kB(Xt%kUK%+7_4oc@{Uv!(2HUEK-jz1iX@?+m2=iGX)juTIK!BZ%7>f;e&ebzkh( z>dTTiR}=I1dMgQv;JJiEozIXpy+y^OP(aRr-?G{G;~b(dn$i=vX3Ex26lC&R@Q`A3 zO3MJnbW&&u`ZTMxEK}Rf4}~TBC_x!9^|fSEFsiP`bEjzfWsHJC5VY&w)5`YKcPX5NgQ{z(4_Cig zx0W&g-p0{LckfJbj0Y|3PZO&3P9eSQVEnP~6(HW|uETgBX{`#)WJZML$%5FAICaZ#}jS5Y+;@H`fQ&_QAT;k#A1P>KPkEF zg#FNxKY9-bnhpO#I3Iv5Jl;>XRV=KH_9-G_u6JMR#eJW(jWdlrrl-~eMOyk$(#PJ~dT1t!*Ba{Pc0{Pk!x z2OGW;>fLX*sA;t6v>t}8>!>BRBAy|y_%bT`0pfJT_pJxP6T%E^HZ#=KtTcOLo^$To zjv!r)pK>UMwuYr{N~rbLw2 z;iP(Qk-)Z$A^RIao)bO`vG|4Wy=64kJXSc?(a_i~=q|SEHGSFiD2JiijCANuwqAn0 zF{N=3d#9u<%X&uPDTuYUI6@;NBR=IlhWet<=%{%d~;bn2Y@DS}vqK~)6| zm>*mEhlivZVjnE~(^UWAVtgHly|*@ylEbT3`JwRq)?74yFvO;pTzKDe^g9EtHWv@Q z#8bDiG_GM`wUq+0O$-xZ+7ZF)8O6@z)odu_-`ja5QNUOth1OkN>!V*!*j(+w3g=aW zh)=;(yB}Gs%yfUxyDc-$v|QZ@GAfaZ(B&l6V_tsGbX5v)v~A~nMOw01zqgrhiMYw` z^u)LgKwKgI>AY*vl$Q2Iv7|O5 zLe;A=L!^lCnRqp-ZOQ_kb1Uk)j!YngU&B_ev>BpNx|i?0;KqFrq2hH=f7_wNEnoG( z4CV$@`bp9)>F9WaqM?W#98GHWF2=JJZA-#yZE3ejFT$&n# zoICx{nN0i+Jr<>AWm#}aT01vf+wXplMw8A45N0(suC2{n!z1wI4xsnoDvZpnYPl3A%Z*%wiCo!w2B|mJCOMo;BP_Mtx2%eg&*L zQOtZBL_94tR~#&MQ#z%wh6B{t(3e0(9}7(lf`Bk+B4*qX;aQ69d2{i{l0!_)$y!dw zM3z$V3GEFY_)oE}9}R$B-PkSG!@USuyK+CnP!=W!ocl9+ohN-T;c(`n>Q1eHEUnJT z!ZR#OU~L4J>%STjB|5c=NpT<}malb-dHNj=kIC%ZYn3w0?L)xvV=E4i?Jazy!HIwo z0?;u%Ih-3=ElKd&WvI2#X}}nAenxlgG<;3E@&y2^Q)nh#Fl{+1m+YpM2Y_~G38~3! zOzEs|u4CdW_%3@A8QzOuU!CmPtLmAb9tsWO0~ouGUsf!lmOsb(evO&_Q|T?(YRSFl zApeQeK*D?s$-z8Ce@Ix`_-(TPJbudkkUwT56d7lHdt5b*B7VSKtGr!2LGOT8A)^r3j{I z%*>XNPYw8^u)M5PT7^KY=P!w1(}knal!vSd_0}h~;~dTzhVEj)e=XzisFsh<0GhRx ziO+ZuW^+Et7PlIO_h4cR0>5zZdBtmxYMv5h$N_!d&k_V2Ubgwto+t!Ws}Zg@k~$Bq z5hL9|=~XgR>Bo-W;c&ENDUzIdunMO1a0ICrcMVG=?Tph_^v>$9Ya4{U2)K0sZN>aR z<}JUFFe2Bz%HH9v=Z!3-ymDn`hh!FvqBzLD7jfAy+* zr3_ztM5gIW5DUqy{8Xa~=Ex_^l{UwJxu$Eq)9W)g{zaZNBWKm>&`lsM_?1zXuT5369U-8#x+L4KYO{Izk1Cx~%QxnC!yHtxF$jpbR9} zU>lrRPg}v~Y;X7IKlR6#Ph?)vRG(4HaK9++t?8eUaL_cesXD3h3%@2W!U!i8?tr%r zJjO721n~;SbdUHO%PuDCGw~b7^m`zsU1n*;RC#q}Ha*kvC+`4fdid62+;O()@SkZM z^D;I$rDDS;I(tiD=G-=o=QbRml1-Cg8W!INn$*@EmY2L9zh`>1W+iGIMl_#!9nr^J zM=ap<4A(tbfF>Rz7+2BLTG(cyq}>9>Q0s4rkOvG%=hX)^9XzJd9woc6jA;>v0lzkZi>6`&L?!{_ak*-S8;oXb$@>upJJ*+7hGAs zAk`XwX${SUokU5o!w+(O-oIplHp2e=B~E*zkWk8We4|1~>Y&$8Ze(~j%(zb`xzg9S zZDPxbM%ao1Eh+(wSxc67QmHmga!R11pqs7RA=2ner?L61RbiCtcVtQnke@KW`_$BP zkBKh~o(o)HV)djv4vj`pRQxaW$o~HO|lFq*t}8$0g(U9<|kU?FE~`C^#uJREq-h7C@|J$XWo+fL<~GkOa0kDwlPUaCfd(8`(e6_!^%c9qbjuWBj;RRCsCN z@DZ6dW|)pv`6G=`9y1l*$kLVVXIw!_G1)qq$6zdR;aG=$O-Ms4Ei}Y-_wK#k0m-@H z{;Wpd5WIC;NJ@_%Vl0#tlhoF;TDi2?4KO6kxHR32)jxOSmN_4fs0SI2=`oY4FKQQf zR;se&PWrkhHsrs1WzaW3JK)opq3`ux(|BEA8ruh6f3Tr2E}doT%#s6GIBaD}ORLal--|v2GcUM*q?~fx`h%vAeH4my*V0y!m zqPd}Ke~SKrzuIOkmf8~QFRM$)O~lu0&^|j+xjX;HUuY^WuPt$}UH921YFGwyamniM z06tfcE2K-kRFHR1697$~jgyHO`Ud;?q;YTZ+yn zhhvGG=c=`44dB1;Y5`QhBr8~zu6{pslu&v*bKM<`xwgc=TaGY1zMI~6p%PwUuQxq4 zbFr{FOwr`MJ4>@G7GFR>?X3Hiz}mnH+or*u(9SJ^D_q96 zX0A5c&WR_H!FIj5)cF9m(n31Lb-Gkq(j3>_Qo z;mz-PZhQEIP&OjFMuSo^z}lJOcm`PEu}3q6xm$*NlebFrh%C8GdyIErf22fZUVNXx z0_&~VyEd_tLV-6L;C~0PajRTujL5{-x$P?Dku$3i-v~Kgsqa$qjO=KycXU=iKlK%B zDyPU>%YkleZYo36b#4>G_=r&>-7azdeYtY^b?)8%9BkQ8he7!F6GYN0I^82^RK@iRgR7L(q44ji&4@LS1{tW}TIkbLm^g6;@4;xqv}GEyNza>oa@>gT`$r zmq*p~ZJR#P6IE>Q)z6Xo!up6zbWT;ti*l7b;UDN88#00{8UiGgB9T*IIhj>!C~B0P zdqU}s0aQMm$|%|;Y8%=C#%yJvw(o}sC39lr-ICUzryo@arv}J@6|yOUjnRKP!h7_k z95Puf&xcCVv;zA+LYZbHA< zeD*0uuDS_Zh(POsZNv=D2=jVv^c)+WBhgjKnBLhrrGj~SsXoWq>Fa;u>Ys>Vs{q3N z`~gnJ=v?apkV`ngah)fodi=+RM?h@YIwI#L`$Ir zY6XE~UD@}2e@d65Pwm`VuQ3>nwrh{f@Th%s=~o@AY@5 zCnjWd1$1H0M5*A-8(`mwK-W<_)yZCK9Z#gFPDDs!!LX@D)`M^?eK<^n-tm^~ttz0*8gW%|BYG(K5e(_()$lz-pDF z*7W_FmQ{$_Jraq(_cv)Q0vAuZRH(K>!y#gOM3ZeeBPJqGfqQ!ulFnHLnl9v)hpa?N z5@>OT{~Z6?^Df0$RiM6?-epzN82~)5{N?*7rS;HWSO-}~i?|~P$X=VGYQG14~tER#|%mB)Wv->GkwBkwI@EM zM_jdcueK+@K`AYf_n33cCv)Up*xihFmO|s1E`T~;)eyUS6I{l#mkhZ-uR3g9Ul`?} z{!|j=J-AqNh~ZEwF}(K1KLg4(2274NqDND|%{13kr-k`Qs12I(T;nR)TjA-kwm0r9 zlavvYp6MHEU&sd+{-=EI_gY(Uu!Cq+=m_UDwF+>8N>ham16B3}h`Zi=1$2uzpy6>| zgDTi%P2Gtt?#QD;s3-555Z`SH4lcY+(`3Zxu)B7s{}C3!S`9G?PbKH!z1YGcm<`I1 zWUWe?C&a_~@!~Ihrp5Op&N2}^)>pa$in^3VN+e{}-y1%BpU3D&0tCe+a6y0OXO<2U z)#F=1FVYPHMH&+Cf0xU;7E=L~_$SefDzIw?XjChgX5*mtGSjbkfJ{IHcRm(i;FIgZNvC1xEJvdv_~*5)KP5f#8%KU3a;h4a|{q_Asn zev|m;-vE&oP_y~HNspf1yG0+xl~b|MI_kq%5&v(Ez%_{Qrc|Q4vl+AZp<7*4i+t4_|%1~`^;4L+m zTG~p@c8*t9wn?(C*tO_h22=0QTu8)T`>o#KCkNp(p-N|QHQ%)u**c@Ih^5sU{g`-V zUA{(dp*??G(>`Cr>_9ar*f%X@sG2>{-2FZ!(-oAmXDFaLUnVMErcNPE-$%ZmfuXK&-UWb`;WE7#hG7fG!<55P86qq{Uw+*yQxmf?)K^&m&q0RbU zepfo;yCYN5tajbxrT8|u3`|`0rJ>OCL=CLLN*HVr?7D3P2}vc`l-LPz{00b zD|lT(3Lko*enu;3Uxy+8XVJ3(EbPRKmhfwn9ZMpkw>7~_y}#o={!lUL>hj*cu;uPG zKuqPCms6TCEMfh)n~qFDz3jsxDv6AooNi@0K z0U;iX#>r#IUmT9Ot#zA3yJwnWta@7ESIsncnqib&N3!1;OypZt`!cjvjV0f!?`2a} z-KCT9e9t-3#s~h3P*Z1vSVz;s-AxClba7(&SQ@(e=Zxjcta!xJLLJ&B+gF!2jiP}B zBIs3ObCx|i!?E3MKl+z;^4j?PQ>x^9xf8MJ`(&#$zj}gSdOg3=_ zbj^)s1+)|ia1_z&w(2K2-?6ptj5t=ZNt5sb7R_=FOMNY4o1vGT&#+L*o-?2MWF$WZ za>k5g=1u_b#dii?B3)TiUrM)FzWf#s%xIHA5>*a>Ov%w=jFYHe&%&)6K0G!t&ZbDXnU$FFvFi6Fvg0)jlP@Zf{DIL$OV5{nbrJM;~-9stSgV zp^#^2L7<6fHpeVSD{GrV?7!|-px*97W>LnpPz z=Q_v!nQD|8=V&um>j)&^sPp`kBymw+^Idoa-mA}pNc+zSx-?AcO#HAf-np@dt3(cD z6#|XB2OU!s@U|xCO_xAjNa=jm!}DaRc%Gt`%;f$VW?$h%(mSWt$|p?EDly_MsEHQu zo*5hY?`mVvXd)~OA7)lHI=EqCSTTR+|7>!K#!wkLScK>oD&}R*k<=Fy6~u4Hd?Adf zKH!piV4bl!78@QFhfP~W(wLk&&-xpQlSHed)EURCL^5gHN` zX~qFFmUHAC{dg7%JlE}5obA5#?W+tw!qDs$Eu0XhY;&eHE=MB!u4maz%!M|s9w)p? z*2vnjF|L=f>fC|`W7s%WMHa05XO@tJ17&U+!tHM0N;FuZ7 zC%1$~Su3+J;Q+kGiLWTx(DicUAP3TyF)zcX`MwSsn7WG-_@bBMop>+3#^~XGh>O@_ z(bh8e?BEdKShWNe-{_QSB)!id@wE!7`lbiAG=TG(o^J)R%M4p<8CQr&Z3X!KesaEm zf5s$f;25~-&m`HI5z@tx?46|?nN9an#_*I1C~Im4X&01*%}6GqVS#L_b$Ueq#H_=NYXzjU+y=4gh*BI#oq6! zkGkWGAl}QLe0cM?*&f={ygf7H`HVV=&cW-YFSO}}kU}GH+9_^};{Hk{7*aKL4 zXd@tZ)+15BDEFv@x1I}}3ZeGPXzsOU6bM!GoSN6~$(T6Z6i3giRA`uD0H52*vN1cK z!y3*IBi^L{1^_ZPyOudBj`HQZP8?I*k+&Mr5KO+t9is@vfGO@|ZxM7!VUPx-WeyQ0 z&>0BSs!he~8wKETa$R06>Gyf#!+MM|mnCK7aO1vr0zc3@IXvH!oSgJwA7Xe)i92G= zHfoR1`RF>fkQPmyq1z^roVVg;Z@|RiPhRm)0WtJF-W5!&-+{|GQu%Xf^qjwo4ZcQO zV;|h=rq)*~DSZclxVIs`6$4JmtV{7vNtsR*^vQ-hJ`stISs<0jF^E{FfB~jH*_2Ur zvC3^9qoR=^7QL5WZvD#?@JNbqm+ZK|B-&$E-Ysp~!p=HiCi^E}uxZTD_RPPVIePJ$ zw%{l?{=KIOmQ4;x3U#^TVT3=HCZ3-y%%pIr0`yWFC2BHz1(L>${?$WX4hHtM6xZL< z9B^hSh-Rf8*@wEXn|jx6Jcg``Om{wY5E=K}ejW@ypk}FUji;`YUIm_4PXcQ4q`s{q z2JonDu?;Yt-+)5qC_NCvvEN~qQy&!A?sfe=3GI9Jv9u%hZ7LnX^qT!osVv>x6q-cB zyKsWp8v`OE{ydZHI0-Ge0P}#8jw(`#cAoL%?%xOM*;>=@LE5q&Zd5 z%`8GlByWSP<&ecqlN9=E;X%Jik5i~~zV-9@J+kd`@Fq``Bqh##eM>;j%!}LGE;VgV zGd+x7bq>1$EyHNLmNEw@f`PAFs8+(I(g2=`w9e$J$s~RzV-)Jkhr4+z$X7o@LlW1T znj)ndkDktR5~g=@GDc!n42G&wzSaR!-%Qb0{JO~i<4E{^v3Hb$o;^p1B6zkJ)SyP7 z7(bP^h)T*IAG!@ZWChcy@mb85(ptLprNOXebqIQQ+^dY6U3@vpD@ zUH|x}JDB}OuSfJ5cA7N((v|z})+gRhjEKBsCHX7_4A~eY)d7cDBg$Q}f zxf{>wa@iqF1gqn0Vsfm$&+ssf&puE|s7@ZgA(h}dJO$^SS7UNiuPB@yvos->ck|r{ zD9l0!q!5zVI>uu-^8n^912lW>;KaB**-x87cIva|v;5jihR>Dd(?H<7>WQElwx*p9 zjsIp9o0osGNiX;B&h~sd%?fyXqPPurb4;QMDu*z+t#!LG)sI-&(CjoYPTmt;xTR7n z!+97{*ac5$o|b{ki;8)ZUk~0r&_A`nQ`q%SZ$!W2*n%3jdVAh2Z{Uh11X%=atnJU1 z_=LGBHS63-eKzig=~-y7JnvKxhLpPcIPE=J5+V$x4$_G&%WRbg-3e!&uF%JjVyxBYju z$1D)*nBm!8nq4~V=za}vlT{~@-&M3<%QA`FsUa-)XSy3h^J3A)dg-w+CAxZjM?8=x zblQuQW51_D)Ajo}t$!#9Lu%lPTjl!OtS2?7{+E?u+22UJ?|T`(a=@e#y{SMz?v>eFp)*&T^-^RX(S_5;uvVJ0kc)Y4Yhiuw&i6cr8-H#;zVBL_*$%E4 zeMRI000e@`ZMK(lX_?X<-^1zbLzahaD%l4`c;GUg(i)b}KJw6i%s?LLxdoCayy8{+$7sQnLKt_Vcx_h0Jw5nhHYG67t?)uFLO zdi@aIekKB98Q*fyx?`Gb#<_OBQAL(y17x~|WT|TfMoK~8DXQrmbrVz3&}}T6% z0qGr!VJbeJP(-PHhx>Z2iIQNOr>_$peU4ve@6F6(Y7e-C@2WE#+_vIReY=OV-*?-| zY)(J=X?jb<^#@LNEa09a@Y{d)cAMJ8SXYth?)?+ec3d{}KVufFMUR8HMipau-hh zGqq*0+e1gqu!FRsc~O_pddZ*n(cSoqr&?(jW6Y{W-s?@@>9}i*nwauUZ-Th;on6cC zcAsJIVu|(M1=hp|-qv;K%KJOejjT5lLa{Z5_D;VuOg=kv+T&9su#N38s41%cXk_#i z`3n~7$M5c*b8aEo+|rb!Z4`(a-VdgH=0e|vLz{`Yy;Kp+65G`G+_8;}Qp-PzU~SuJ zDT9f4RzBy%NrePmr~G(cofj7|p7fFke%-bbu)xQp%G&6@8L(KO;PP!pAifE}C(?NvIm=e@@_y zKq!pA58+v|QWwuQQ&s3h_ANj7y_O;!m|V}_xM{!Z;;@+B5mG0cPn-VAxym^8CT zG|N4!?1+N!{W$x@8I7=ty?9TrvHqn7>}jS{_n`vbGX7X*G!|Irs$iI6_-fH42bk#9 z{msE4=hUR^i9OV366Xwj(+@wwHk`k=1QO?#z<}3mA0aFlX~|!DI`7}oR^KK(e*2~G znOZ$+L=4RDoBUpGP0V;LTgie)vfibApQs69|IHP7(vc;|uhFTsqb99;HNoCj#k`5` zXUO5Y7Um*G#S=|=N;O4YkYCd)h`UYb5<%csvZ*JOezXMJZ|}=QGg5N3V2t<7_b#J6 z(o#%{*&Z#ll0y*&myw6<#f5AjrRk?U)oA1lOV_UmLSb17z0n$YP2@+_*qUqi@-1qx z`F1LQgvzL$XJDqWkN;|(HF(yG5_DY)$4Y)GZo`K7w812Jpl9JSq7F(`B|PCyqt#f@ zd~a`{QLuMqg8*|qen2x*tM2`IgJZ%`tul8=?PV_O^chE_V+;*uY12?0dB}m(ro@(1mQROY+DzDu;Z$ zDJt@_6+nx05G=O^SwTO}NL)2XEvxCc+xN247JP1iJRhj>m_;E;dgmkv$H|=h9X&oC zmE)fSU`x+6As1@C}ICuPz;cD{Ae&OyFAFCBv|Uz2kio3!*p!%psVh*YPMX!jL#2YZS1H@4VzZcuM?py8=^r#G8ekq?To4vEs z_$vXO9YiX+hZ82ijCIr?F;gJq<6#?9Q8tU)T3G_b&)LH)0t@r_l#Gl(&J%FdaioM; z{&huj=D_EMy~;LpN~ZQ}6L}B(=QtZIzQrt)9@l$lnIa=`M((k&gj|~3;%!{JaArzK zGPKZG*8Zlp8o`x7Yq^GqhFb13jugW4!Ijzud!;AW{PrGG(hA6!;(3Vto0-_EpAJh4?@y#1~*dXp{8y%4S*D;$!!oBu->|zewm$DYu%5=cGD($|^H8Iv zem7LBmf+3b<&Ij-l`)AJUnR~s6R*ck7%R29s_8_Gy7#cA4E=-*8KRMtIQd|OQSHaj z5Hwnz!tS3W_9jpYJIB8>+3%CC*iek(sgbjs2z^w0Wd2&Qz~o+PgWpbgzI0QmMP`c{ z6n%csEc99B>K`sY{3ub-(3EX)O~zt@{+|2f6uMWs;cJw?%cOZ17drW0>LFCTP$xaO-%F9wqwAjDj`6J! z^n{(&Zy#zB!!e0Gkp;*f3C=CswhbS}_XItPWnve+inK3B^jo03*FK?1iU-Nu+bbvB z+uP-o7$^@lNVV|P!=BR{--ga1-p5k)d9u`VWP)g=xlW2Kg(#Eaw7nd;T^5~6jhKT3 z%|Yf{aM?#FJ#{DlPxUowRa-U;Le45j^M+(tw(tYv+2@rRa6NpcyAqpf~crn*#g1@SO}3+9>v|- ztn>`=X~eWH)99c-QM-z~9m8ruWEX70d=-}=EjVQS}1 z4@}W=aTqUd}Tk zRAU6Sch;WC+c7PY8Eu8MA+51-h<(q(%oA>(UQ4b+zMU(!`)x}2@8^2L1+$z)(-}K0 z=2&xd0{uGE6eRxX72vT3%XMW<)}^>2;nEbVRB<7fa%%Nf4KO>^L^1cE-h~D!m}fr{ zC}{}-Yyb5@`L_#{r;%BH^2l0knzk!(AvbunD8Q}2Fp+szCwz1gr<(6qxW0G?OQv#u zkCfw5h{CjFQ&whQAba>Mx$F01%C20xnnu^eEEPGi2pn-(`?WRh(vLN?ua_V&hmlWc z&N6eBPc0bgu(&**LW0r&Kpz~budZ{h)$b>2cm;Qc30g~Eo_s}UJC465xwa-}F z9ZnqO%|xr4nX1g4kj6d}C8*b+7+BUpYpy&USD*-Pl*}VkSbM9`uV1v)qK_rv-1T1S z2-@({?y`dBQaRI`zUs9#`?-r^slWH;Rgnv?+R1xmrbEkG-u8V@%0|#ZXMo+qxTd)w z=bxxrGN0GffR@8>r~Leb;+;`(ECt)I%^AsMuhH!YWy`hre&`Ts5uSA>W@po7{3=J& z`hAU7y?08ZD`l28#8Nf?(kivr3>s#6X2{c9LLD7UZ%`f%X8-Enz|5Ae!r#*9ziCuQ zAQwmweYa1&x4NXhsi(s<^>jtCY;Oh)^7m=TIs^$WyI!Pj!OFwPSqAC-+46z$cz=KQ z)OuR7lr1FF=eR*3X81kTz3G`AM7i6hLUmF^nWl8+30Bef;@5Y~z&d}WOdD(?I0Iuf zvrt8-;~ZjvK#6N!r7gN%P?0%pEz%Pv0mkr!zcaL<)F*fSmUGolyB5wOSK zwgEZ!hP5Txf3J3%Hl({eB)(oY!+9fuV$jAqw{%VPBKsTsWf1;7IQ)$rIq>Gdrgm{9 z;8U+~W*JoCIYD=4lcGuvSeLCl3*6^l=f!ECCLV0=m%De|Gkw{zQ8*%MZ)2k(aPeY4}WA3+mpyU7G4B#TsC0 z>7VN_0Y!V(l)}y0e-X5^Nvc`;GS&!@|Kym|`o6f&wj`I7i($dNnl*~5PRQR_OKPOl zLsj#g0M!-1Te!hbQ>tde)DLSiw`zbUk&af5e9dJ43Ey| z?9-8T9;r%O8yYwV+_73e*%8byRtJl-f;2nwnn@PTHtI<)6vCvo#gUL1XoeDND}HP1 zJhGIK#GwOVZBxUX+DdV2p3O9Ajg(liYc#*dk=QNeJ-~@rm&OCDA#o+zEB`kzhQEgn zqHw7~a-wEjaU|}#9GSN{@UEHKC~h#0Xff8hEkJ&TIzpR9HDE&J<7`cYs*4@eKV||{ zJ7;9RZE89MrRCk9XIgObJ?OkDY`QKgcqKIY_&yC`4mM+-$!_{Sw^&a196#g2dBzK8 zJCjLkP3&R(;zjia(#4~!3$i(%33)YqWE?P+tXFaMK}*P?1Zo4^edXWB*hu~NLXLEk z8f|ITw+&*_!wdXo;ys(SKBN&_POS8u%&e@2xyAQML)wA{DTJ@yNGvJ#!JKS$&hss5 z$#wAkjGY`gg8`}x?w6V)2E@3_OMbtR1xI9;r`ZZ(TdZ=9Jq(wsvMavs zCaY74taYZ1zs~mi{ROvJO2l4(aF+%za@@H#0tsA{(^hfT*|FRDPGqm7Xh}p#wIqRy z?aFeExb>(A5BI+;00pnw=Hqz(ds1d!{C-4Om+>-L=mbtLeMiqwW}G2Zb1~2M*BD2m z;FMF^nLR)c%aAVc$IxBiU?ly#YD_~fUTZgNpRbU8rn4U%!||J8LxW0B+r|X_UheoA zP*!PG(6>g4`biroS$v|2B8GXL=Il6viW3u<40eQ7xv&B!q*STQ+ygPlYij^%r%aRu zi1tj+n&{Z(tO@>$fcox3hxWlIDEIH)&gf4t6f)N{EEJOvU?{VmX}_~;gA(5I6N8@N z=y>6*;_aM{U+N(U8fD z?Hd7s#gV?|oCHv^5$yue(j(+l8LKg!_!^$DEoO66SYzpvu z*M!cN90c%a$*g>Q_MxhfJl@1Y!7gU{ronrc-*8$(&W$N|=yV!8hPpYiU>{30n-W$){VggXB@`}9{2}NpzH#?;L}jOn-n8eeMFSd10{uW1 z7YTuKC;uRJPK%w6GKBJ78uq93SMSPp4W0G#Q{^h71H%O=?4AZRRp-yPOHFLf86 z;PLWp05?sXu^hLJ?855n-t(u44t;XdzQU(EEu`4nC=R@PoB@tSZz_V8hm-1h{3`1x zV0sPutj9|w{g!=|s?C?A0XS9mPI}cow=Hud;V0B(ve3dZnst6W)?}!6=Kx*<%O;D_ zQblpZT1HX)zN4k{K?#1EV^dbvM<_Wl4>^q2uURBVoj@uq((huYZxR{y)(Kl)MlQyC zX;b#o*$3^F@JaM#y-b`HPkJ#fQbX}YSR#3$r6YE2aVn1dd_rk~bW_)X-D$TNzn#;` zGc}F`g7Bxe386{xWh`i<@p&(PdIFdI|8o`O+P*Sm!;gv3n9smP^+mmg_b(=R zF*(X)_S0Vs8tt-!^Jt>n4KWdIl*XRG$zagTwmBszs7;?1eVrVR)4p8 zuOYKP9n4EzPj^Ei|EYys|2*^PSsivhpSNuw%GBz<42qVzpAE-$S{{kWm31#r#y_kk z;a2#OxOX9Aq9n6T5(9Hc@Miji}sBa7_y!=nmr)vi6nlkZs zxf4H)Kz2K!Q3}kvzpA2@3+G6Bqz1dV!?RWIuj%L~5mvDT)n*SX_!G9q%B&S;_+W>N z3QLfx;{uvKWiL4qWs6{^Oc*Qzz%V*|S$RLSxZH8}e`<)|#W$o>BOd8%u2HR)^~U&n zUDxDmd+nSjyzFm(EOEvs(%Q0*W{aprN{uowGRc2!hAwP(1EtF?gm2C#VLE+TePRUiU$|TLf_39D#5E>pTFieQYK-_L-GpzHZ zHge)`5GV^^0}zuKhoY-zvi&-^XA~C%E+pMh-oa473ly=n9DgUSMI>sLgWe@4hC3mRltpu;GdZ>k4vZ0Gzl zKl*#;KO=`hx+@NM8>s3TW{WOsoyIcocuGy?`YD$DE4ot0fQ}o7kuD~UPoF8yiTj=N zDd~i2Ob_TK=h}%>Qf&T&M!y)O4E)w^BZTKo+&=4fYISbq5$U0RrZhrxAcru?u0U4w zch;?~Qsj{ByR-K>-UJeBL(Q1@l~4Fe<3!#+Z~t{om2*8~e6hmo8xy5`?jIljGwp;E zzyyNv<;_f-;-?_>0=Ud)I^Qz`9rEz&Ha#yj`m94&xO9h{0(%ZtQP%2m86kpjRvB_|=-^-P-=D2-5(0$}|6OKHKZ)X$`^0u_CWP^02!;*Fogn0&ex(^R0& z^I$6)6~b0i>E8via6Nw)wfE8|!Qm0r7ngJ$6xP*lZ#;d6NRstsz<>|A5P^9lmw`;k1wY%U_ogO>_PP;qPqW;rw9k;X zJ0)68GD(g%D;3615V`Ly)vvkALq0pRNStuR-q_z`(kV3!ZZ;`giMiLYf@Qf zN`;76M!1N2u1pYI#>)Y9K<-YxB z)HJP6VVV2$yW2yDuX0ZqzkFV;rv1UhiNA2*x!Yduh~U(>sk=m-h*;T?SR*TJx5&GS z*hWUBSAnb79Qmx#s7{2XXCOgbEYQ7L)qhT&PomSEW%v5qO5U6;)^sPM&k%#Gw4XpX zcCgByhg$9i6fUVlvkvqF*V4+@KB4@)*a^+8g3u6LDftXluZj8;$*na`e*6|0w4Qiu z(CdG`a9zw=mxKdzwd-_3T^6$*8GMZ%2?7*vloWL^cUnJEcELu;{hUCstL66er8eD> zOAdy!45?8VUse7+gDhfFdIO<{XBb!w@9!}qR35p_7NKn`8rz0|dEj3PSE$hgL?bz+vb<Tn7hYd0oz5Yw{W!xN;!$`gmaKDp4|Tzzh(-Xz%oM%1*`yY@3^K}Maa0ZT^UYdfZ+@ZBO0 zH#^Y`4CFw$e>b~spU-nQ;rUFo4V{6gX|a&|2m`Xfm0h|FrzTZ~LJhjGzCbVj*;d_& zUArX)Kj8;`K)q}d*E5a(mH?A1gKCIGf8YO-ZFF$k(vG-e@8!-o4gQgeu9i(Z_xFDz zCGj9!GvXmoy-GPvPmykJoL1XA&eRAKmyn;+A$pA>bB0~{K1Y6rlo`&q(55+#d+6Bs zp~8EBC}-)7^%O?nM015Ia;xv(jGJ@jQzb+@SIcK^Td(IM3`Irp)+p8A%dnj%oTekEHI8B&`lv+e=bTbGG%5f6E7q#<#9Ye)nCJ-fNu% zIeKK(suP3zemLhy%$&K@Lum9`TcHZL*yqn|hbKWZ)T~&I&fq&it-bXL?HlB9x zgma?gntO%-xv04izrUrkv^tE|W=S#C&xrEzJUj()HEk{cs&K9!jd7y~ErTEs-V5PA zgSB{e8j@b07xI78k1t$YCeD)tOXWp_4cDFgW;Ll@tZ$=|_&n2Yg14DASZ}4$2(oV( zMyG*m;BE+!e&}G2$4eO{?7meobupc|)rqv?sOAl^DD;6FzlgKjof6dL*HnM-_f|(?6=0 z$ISzc@VVk(`09;qv=_{*um62{eUaL#1ZdAWCm-YLWg9ltgtOFLw)K5FQTIGJx7|G3 zsl`Tw=G0+AwNK-Uwns1&S6xqL(rlvqB*le(Jy2K&V{Iq`@NCYb zE57?Fl&EE>5=9#+u7AhFvyfSFIOimlS(~=ZuBmyNACW5k&*7)79==BSMpMrFcP!PBtuFcO#E(OV;TbuoLD*{cY5?auEc2bMGTM;nB zhwMq8k0b}LQ1cASQZ9Qh-SyhV)2C^NF&(TkL_{sb@!yhV(kf6;8F%s9M`keh+UY>d z$(#(z@!TtQzu!wiG>y9UIXV@oyL#t`8?^n2COX-g*NG$lQ3|H@DTGP0Xj-3x^c7Vv z256sFVo=qRs=gQ82{FVhH>|YTwy)KM+Go)Rn^lCF-4TBSZ~SKnN}a4IFh&c`7-C)x z0M+`1)s4q1Vw;MpcazK{-tgOS zQ7Ik4_a;?@{XyE?b-Y8iCGmIQTgJgW5o9KoV|Xt|+*?QmHU(f(ABUmLZFbV^2=D7N ztWaM*|L?v#`k_l@if15hbmVBw#VLjeqLKj{VdyoZ|$osFIOM3#<5@1sSqbSTq@coy=Tpaa$I%Ro_jWV8fpD;LGj8947%jCx} zlaa;JhYji*e$WA1bj$T)J0_g*D$z-AFE;Z$MqmYuV%!>#cQZ9n{~V~6H;b+yntsx5(4%OGRM*e5mUsfNF!A~~Hk;d*;WjqYUUrQ3Wj zv|b$2uKIl(W&gx0JTOAf5@*J_xwo}%NrBhdf(g-cJZ(KT&Oq*!gZvZ2ui`E>jiHCV zF>Mi<)svpF**zHur!{lUFRSnlCrt6g)Af4_CZ$=(yohdX^@Pt>gOAeiDDKDtr>`Le z_$N=JcT}nsBlQQ~|3CjxAsS@nka+HQL^k7Evy<>9k zaorQAZ#y#Z{hz<7<-~vzrql5+*5byZ>Q?=w1-`%Dxc+;rWE2?-2-Cy5hsd4SpKI%T zUNmth=yJ~;p^q<$$z~&+Cu@c+XktK=YsG~CG^lj{RW_l5fbez zqc2%_lo9YJmsX{cIi?z>GKe*E*{AGPl#nt$4u`1l1|~SikjS_3*62BT!3w_Ks-@1P zDW4gZ^m}$1JJl z$P~y_`dZ&~>2M?`Zx*68jNtpKca_NzF^O`IzaU9rka6ZiovVrhcnhro{)SHm`-T)7 z<}ID>HWMx@hOSB4??bhzt9Yze@Zzfjlymj7D=@ptjW8VdjzFXQ5v z$ios0d9N!bO`q)aPD>Cwy)f#3LHP-E=k_slq+U`E?SGCszw9^lR28HPox05R@;b{u<;a(@rO z8`C)n(pUD-ga_@Jwgx4FTQ?ExH?h%Qv|?$fDSt;oum4Pl`-|_ca!v%Hi`w(|?z3Fz zN~RkT?%U$6KV7r5fTGU%Jz&yhIotH0;CGJ~_T&H0w2c#P0kBazwzG%Tkq6 zs2B@f`+G4%HsPb5SNWN5X?4zOOtWV_hMBE*ZT(^Mg~bhINyy0Qsv;gIa*;e+unWy~ z3WOM8p8_E{x#&yHms=yaS5ql0(Oy60p|yGP;nf9GrTn__dW&kuJFeKTyD@wY0MiTA z{(ku!GPb3W65@@QK?z$(s_JX>+;@VW#q^^yhBO2{tycGEh6e`qzD)!7PhSCW9tB7P zc0Xg_c+OV;S^n42aE^W7nv+7dsKN1SnD*L^?jR590;|WWLaMWF+(XN;W+Eku0iG~% zvVGL9F?k}T5;R3pd??G=OSU!pVRJD@KgZ$g$vpIWx{Gz$63zbx1=pa3pMg3(QDUaV z3g<<5IN8Hjen*D~dX7=@VMu5Gbi?l)5O~7sy@T#Kpx>T(1`lae{{uKvgcwLu77{DZL^YZ9WnXRwJJLt{MxS6aL8;1!S=Uqyz)-eMXig1)e-afWV+ks>1(v z%JLnCyed< zebn7C_x-CiTIr6?GjPMY4MhodvnI>^YN~()4Vz2gsdv0K<9X=Ielwc?{hyr-WJdB8 z%D>NAO-iBo&cxR@)W;w=Pq3)Z=+c9L^o-QKAPu8uU;w~J(Vd2k?w!N}`vkb=;n5Ldnh2&G|PlMkgmFGJK4N}g3 zD(VRp3>KfLQaGk0S7vsx;h*1t=^jk=281qu;`sr(bdJ_kN0WeY6Ltb7C?S(fXpWtw z?3$)320uwxwPUZirle|gqfat&tBMt>E}Fip2eTr z<6|2(U|naQFxMp03PqG6=ZY7_G&M28MPBbkm+ybksQ>4CvMntIH=|blzNffzwMMlC zB~1@iJF|w&+-Km)Cs+f4Jm>Nd!HFxF#y-aE<9+KiGb{nFzdu{ug~q#O;*D`- zs45?e>YJW#@-H>LZ=sT43R8CQaUIiM|8GyF{28POFT(Od-OdzEoV&Yi08_LNvSn+x z7B3@cj`c54{A1;wKN`y07Pp@Nr2w!iDsfOMHHm*NjEwEI*E=6y4jnCHSkF=;%2s-x zR15}c^K~|Cl2e2fhnN4K#=$dZ=?QJ!OL6KYmL75s&hLD2&XP;S9Uz%c{uM`0-nY9k z;eTGGIi7`HW%j;*wZ=26nphh$eH+jaqP63^EYjlpczi$JTg}dA(DYa51`NaHzpkE& zzt0)bGK0qZXD8rii(RRtJ!hBmTC&kENzgg~e0P>B2l+iwPv7N0Mp9rj~W@JxzDxy^;WCq)%;3f3o~q&)>J9NMa?||9xja|8h6W zJt;Wh-eZxyF|v<|!wW+_MK`9fw8`n6E?GWgTrhxl!)1xx#~YJ z9I|;pe!kU}wQl<<`CYj@9RGh@oCRvtez9}jMPJEXdU{D4J~PG znE}Uyd>eM*kiu}P35F{{v$)@VH~*ybzfy_Qs~@4p_2yL^q5zgZLcZU1AK?dOQoq+b zjZtJHmNT2sIGX+4?_(&C?*(PV>ZRVi2BLpZ=I+C@cC9``uD!_|pi@`xJL+dG2GeaZQC z=6jn4pts{_jK7Qs;qyJB$t&N=vmZ{IwQse_wg~7)aHZd$G^tI#lxaue7%Hw`2`yU+={xs<0R3g*}>AmCQ3b>nG4xH=W#S_Mu>O+}FmO;Q#y>p)p&Z zwHzalY~#^zCXEk!vPJeY2i`)AFjYgJ-^^_7P<|{w^d!y!m4XKIx9p#b&A9BfVWdI8 zpHyuKBHAfnkU@e|W)V}BLpRMgd8Vr@!A_Z~SG_o`xP6vX&H$OG0z*1A+|8bLUH@ZZ z>#3y3XpZoCKQ%7S3;7jcS`XPBM)>1z*mRw9gn4x9)i5(*3^9G`A**vbQdjcYR8s8J;?BsPl-P5lNoss zH>?G}o6B;lOjk{nPp(&MF94ch{iZ%P0VQPD?Zv9 z0IqdIr!pofPh0!%!t5NGEPCnpzusHB4yU-51ACCeg)0$bro*}fj&%+W@O`Hy{3nhb z8;oZNGQC;4wUdwc`+>{pz((<1%1o+++zEg#1(B|Cg&XA!o>HT&UoP>TuAd^1=k<4` zL!nYw>_hL|Mi%y6>VowBn964ZRvMAQr{^sMQw2MxNsc99fL1@@9;wKr1@SlSU#a;>wANC5(=(Y?*PLfxoG8{0~yp#9Y7feNLAvROHBBlQWd)zYnt^m z(@NTXfQ>rtJMZW3>gO&REPhCeX!S;iPps?-7Nu2DMJMi={Rm!qMVZ{PY-wqwu0p0Q z_VFl|jzZDVzAG~5!4GGkih=in|Bdam++%RWk2wZF#KnCUnC(31Xyn}97ps~0>i@=S z#DQ%xzD0})t&3-wdafG>>m^#g&9%^v(`z*6C_b)#wriJm|9`@~1TSvKNh&7d^ zV1$Axm-`s7?!Cr8(W?G?0ZAA{gg}9*c;Abi3G0pZ2OUKE-IQR_H}G_S55vieINKIN zhpTG%pP;U(0vv9S;k^0(JjI3epiS`>5(%gTq?<6+``q^SzipU8q`$ z?Rqr5_r3i!cwFq=2dQEAySG*UN4+I-OlO3agr!p|tM1^^7QG?{s;t8u7>~UDAcx!7 z|Lv9JEmxso-T$Kq6j2uIwEE6s<<0vg^!OR`{2F2ealb?Ik_L>W>$_mS+uS`e-4V#;zJWj^a942%ntP`q1_ntSEJqP-LsfGb=&HQv~Oh zNqbD)MZDoijRLgK{?rruy+8)OJQ8Nf%-)K__u#=5T<#>UQctyygSK<v;oII?&ot+2Skr zb@(+_a6>Ug(RCQa*71sTp7eoy ztCbp`99+v%+C(4dGgIvE2eMsE=%}LZTSRzW)|amg0JiOQ=~7hRPrXE~sDGP3*)k23 z2-uh3)lRpjsUB+6^%;W<0m7XM7yq_)4O4`2+K@QG#CNqcOoPqe_OUR2tJm+3NPKKQ zYghF~D+aa~d7+HJ6saw*_ z;cq9XBKY69F7C61RD*9afspRgk&<=ubZyp{4#S4z=Cw}Xosu|-&4e6>KUoDi1~$&^ zJ2KJE7U$f}@omPM5Kz6ZFvX&Ok8j(6Djil0&DOj;s@x|mz(GSgM<$)Z2O~oRVRXH0 zj?B{ExA52oa^qGzte9j+WUW(8l2hLNlxHLc#N>kAm>oZaT+Lh1=WVQO1~Yern?lc<%pW?M{+hN0)1jPUu(~Q3Laz*yH*fjN4N0 zH6)WVBO^fYFWk59qSRN#=lQz1=k+P#^g2hYs8HaG6GbL-r&DC;!=gnNS%RO>5<&iE z_mrC~nXWS*L!i<49bCxGWyxIySIbQ#;!SIIAHPL2mXwn$#4YB~LG%WF%5Wzb#0CjoLmX$Wh#W8jKQaElcvgXBn?{?cvjK4ao5M zZ?vPge_y~16mzRa<_eAL|JD}?o+nByi>%^gTR3P^sAR`7_IA3QMJ;R`IcDHbso{9f z-!f{zGdl;M_-%0#TOogH`Q`f*TI!|Wq$>`;?1fg*l3NLo32c7l(2@ChQ1owy#yXF+ zV;-vkU&f~CQ+zX*n7f~md;k6Px|`3qtV@fhn|A+h0T8*ieP1#awF2L0k@&Y>AI$K$su65FSCJpQW( zyM_NtD>ovn!6+(|+fW#1Yu&T>6owe%xdt2{&ooh}=DQ{@!b6T032sH6ZlO*11gD_X zO8)t85DI(4)ZkjoU-5lfcOLHp(D6!p(4ALFOzx-d>PY|IfVVfTS$LM=V~&4nGlFpM zaGuh+%}D-@#N}K`TphA)-fX7~kZ^;e$J8J0*4gLyGni$cbmpum&+bk2y!LVC<#sgA zX8WiJb)G5m_*jq0T0@=BxD@v&AECNTw^+H<*M^O|T&0yvgchXPFuHKQ;9CkT#urPt zv1x}5#|>>U3Hs4E*w&tTVSbOU-=^!mS@?HQl)-N+L7Aq@splR71p|~5J8>Ugk;1CP zQKxO{m1f~I>E}AC^aFBD@Irqu{-UK!VYaA!X5#w&okkG>r0)Qu#E2j4+EUl5?O=sje7`F zSS5^+%3;hjIemY&$P9pYYBr93%j;ix?E_C`ur5-1ZMBhQ-h2UWZBkIc1R3VDwKr+f z%I~q18Qu@ff}fs|#5lV6GGJ=9;oWT595!5CebRkNTbEaSW)bYB@BGhz^5{1=8@<5O zC!QL>e;$3r&!q3Gby#6jYiitr4RCCezBHLttkIZN)E#}Qy!aqpowIvz9M7re<+UH; zSrM?b?~QM_r=vlg=w!*S{>nb#$zOl}HBe{-B$W=r?f0;eP!2L?M&F1l4ZH$ZzE=}e zaWo78{da!1VYJMFR_IV8>YM<4F*rK17{&xlyf&;`ReuI*n*4u?_cF$-+cQe}@aR8( znP5vsD)hRimj8N1I#9r~$Q6bhhW9yof8aN1VR1Q^h zzSApnp;Z4wFEOv=VnT2W$ZbE(suEB)sU$}d=CbXsX{L*_R_heldUPln&|%s--)af- zRy@<)uiNi1ld1)J;;Q-G%kSgX%9$-e;mYfLrAx)>`5o6ae&uJmwRYxs?J<}BShhsN z>x-8#v(t$lBm80oXxxp1z2Bo-E~(M@=A77C72RGA`?~x>A@c55OuH@qW-`s@<3KYy z(Al2@p^j97joWn$$L_52${KoHfb|2VVH$C2O+Ia5f5) zq1k!U^8FBO_TQz(Q;G7Gzz^|n~|0P#SF?}Ys#fWCFd7rf|56x8F!PF9gb%Xtw> z8#~whH-$~Pz{Yt`o2bWEdpVS)#{{LxELx=Bfq%wcH2sf^Bj9>F9P=^{5tZAoJcEwN z*SmB1{H=|IeWEdPd;eS`f`{=ThJ5#_dUUSa13Mg+I}+0rBq1Tt%bW#9E$ z31jnJWqSK;JuOZok6V`mDWaVDOQ;==L>IH^*Db}(=spDEN#YAza6rx{iXh~;K^*x5 zUY*NF9u3F=@6$#QDX;npuf5-1_`XVne2T2mAYmN{GK(> zGXc{M1;x6ee!_@126UTo%1g!tQ3a7IV*X|^cW&D@GD7P6v=f=F6k&faLB5;obQfnQ z@jNpP4V3||DH@g?+wOdQ?6$A@SbcsxBhnn(XM?*Eo0Sf&A+HlbtZ&eDZaY*H|Da7c8@UF&qR9pwn0;L zZXlU8A4Z%>zh@_bDM+*IiN96c)z0ku?(q4p$p$J)cq62=ZetgD7Ot|(be{AF$xGQO zxGLWuqc z1*HC_p_8aVNO+X>vAr~<6GN~{!(NVO*_9>Vs90f$p zUBtQgH>rOl%0w0;P-p(%A#?Fauw#g{7 zoMjRf+2~0bvO5O(e+mzbj(!XsR71bUy{VRes6fSk`pxUe^1f$^;bFYO|A}p6jb)U( z{md7x`I0woA^d1e51xTq^}!$X*_SDP#44fL97840p__UyBl{WVH4B${mD}*z#}A0p zSf-F`q^uyp&oM?gQiYtM(P)1?-74SiEJyM;wuyGVB0S};_1Nqc@+=oW`3LQhb%lxo zB#%gG?G*3cx$VoE6v@57_oOz@tE0-v2fTaDOmZoLK$^sNfp70jqHKB_;`vh*Xc({V zw!HPPqY9#_?U+3Ts_YfDHBkN`R3hl@^tB~ixXB@P2rLnW(C0{9mZa7o(oZTi2 z4|&ASrQ+KLUSg<;zxJaP!w`<-d&YD_F#Og~+UK+~6JyS}O3ibQJHXib5z=k@k1)YT zMbZJwlb-}lGBY=A(n?3r`?OuM?7PIqJ>?(wa<>9)r;!@&Oy22_Ru;3O8-&jUDE=Jo z%%w2;CZ4I=qQpMeqoIJxA!P&J>^S0M^k&|EY8B>2$E<4D#r zmFy-!<_rpitH}Exu1Fs`sRcEsnod>9yQp(GC~L24;C;7u_YaD_ct+7W%Q(7yvLWGb zaId-Tt&GQ%(1-)uKC{e2Bd&LZg8NW0qA=GH!_b9k|q-?n0lR~T}i%#(bgkJSnd1GFF$zhnmQJ%>}Ep)>7b)4YuH zswx{b_W6T;#)ac4E8%Q>yuL{$Mb)gUqLlQ;;{6uDsOjUhpXnIM{N^sLgu-@ z>-H*pFeY*U2lngd9>4BVKxm`^Qy1M~YmSk$6A>*XN*Fy|?tP{;Mvv=%ye%=ekdMCm z_V@5reBZaX3pt7X)LXvEKnUweP-;$540TQ~%lX5RY0HUWT3lf@r;J{0{*wDPt2HN& zlWbduP{AV{BMiV<#H}zg#?T-8;ZH-2l6~ai{CVARuKBt@lE8_T&(E+cBXnY&-6ih% zDhsqbLFsqsP9vwsXwvEdd|wn{*d-iPm-fCVY>T{Ga2xl`hgqq4=a4ZoeW=2X?+Jzr|fCU9Z=nshBfLdA8Z6xdSyFI?i+XTn8T zpDfSNvVJ@6s&|4(QrrE2?R)I$Q5wNp%U$erw(^S(+;FId+JfDYkFYG)iNyL8>!Kli zUKgUf@*TjvCxzZdh8dY;nTfg?okrFX9mcG#{~jtwK@{7sE7}avp2U_k-0x+PafUP* zDR6L$v2SrkT}6QMJ*&L*P-KpDefJ=fpICrg$2M%-2ZO(oP6c_tX$CT4E;$LV+2g-F zXk`u=(N6Nh-K0t%gyr>zwN{X*Jo%}-E$0MXk@uz5uNN%bk0kXss zPkUkUX=!A7|6k4o(lUn8t^NI#iNg}9<7L>-)nb-lbi_}h(Klk@m6X9CPhbnbeuRZF|*ovuMu`_?8g=1!5YEik~Jc0;d^AKdQcU+ zI=;L0c`ZDyF}H@CH)gnxQM+NT;6zVeQdO|vk~Q0M!o(^-1{6SKacFf0Vwp=%Mj_G} zd@{EcMl=;=;B1w zK>3>c*MxIRNIhVUmR#lmTkOyO<=&*(*J181>ih5e9nsoTmcUx<)Tqtur2P7H?g0?2 z13wWU*Zm#&Aa|f2E!+R)u5y;1G9hy7FBkoq5vI}3CO@q>$Np*tpKM&VH^dz+7WUgr zcxvE_?&S>E$~%eh51~fIw9f1q4S|=rs&}kmSCCVOoQvHhO;*Dx5IR~j-j?{fxp zNUvr1ea5&3U&w*=eKi@!9W4yUAd9-Qf+D&VH-xheyq}>s5)64|Qy@K$;}Z4?8^Rxi zl5d3^U?u2}*b$<)V-BDuu;35WfT`R|*<>{2{}N}f>0R|*BY=mNZzZ^WD}d+(8FY|| z#MB+j1y>PY5RP1pc3kOhM{MP32w4F?x%zxhgwkYIoHL$>j2+O%8~>%v1dmALS=@nHdteB3t@H z?+##H@e0w3Wl}sNlH`J@^^VE?W1D06F+$OE|2^$CpSPH{WrBxW#=e9~*Am$^^&&9A7~(P`j}W3>+?Kv`gBo_^_z!sV_BXVY&e#4c8MqpkXwLht3l@6UTPs0qioy`SMX!x_3-6bllFnhRZzuhIJGX&69Wr1}4*2y?z-Nr(sL+-A59cR9qS9#pk$4U{d ziqA6P$$S^Zf zm7XW|Nm3CVPZpC6oA(uVomks7fiC;Rd;8TClw{uiEzp&+pc?MPVkCRP^h1F1zXa58LKDC(sw_o;q)Zs76g{73e-)s?n z2Kjhh-blq9HUSm`eaV;&C}P;QA7`6Q8@vyPS|95=7`HN9yyYbnmzPZMFG0{=+fhkB zTj%z1{hE9B6?dF6LgJTpa4!Rh3VBAQb8%6bm(&xl^gZj zHS9S}9B>d}I~jIPUpXDhYX6sCyBcYn8Zre_Mp-^G{?@`_^j9>8DuQpn`>oVU2<6&` zAM&Ds_Y60YWLc3b;VZ&~nWf#ZU&jtdeSS8C1w!pJsom zOcRpU>62(=W|8hDcNfFk9QW`0&-*K@8#toipwF9=a4#cE8`op^@CfbtjSLWU^O679 zOes?L@}-9}teL<>REn==5NJN-s)t`xo+Dmfdt{5y{pFD@{6)$ zLj9}0V0Z_uleBp6Zzq>upCW7GQ1M+FW8$1oNJud%yB|AV`=M{uZJA38nxGCz((9cG z#q)>#eOQXwnRvy=i?-Z3jeO6?_5{FcX3|vwT#%w{U55onQq>*r_=W?XU`H3|qSSb>U8sdH05tbbQ1BN(6 zgk2P^jfgR8{Fw{sh^L>4#Wj_usk+6d!ad&`N9~l4(2#5@7SyM3_KDVtrHJkHbN#w% z-kfR2|DsIc-Vl(S-j3JG@?Kvfav}%FQLleUItM!0ge+9E|e;Okb+?A&3bUMBZcYQ%MxK4DT zk!Eq`{uYV^C1Z?@ne)9Amu~@oj*AYRHU~_m>Gv}bbzE9T4zaZ!cdSk?kCvky!NlZ~ z@jKF?YYmk(Hjs4Sg*PpY&-d?^hxafjGbv$8y`GV8-Y4JRs_ZPW?0(aSck#+u037@H zY%#zqTZFgn%iv3kL(cJnCs@@hm3RF9z>_L@DMLI(G6KM3;8(`)th(&UStzRGvj46L z44IR9NsP@Vuq1Wfl9z+ zY)ooi3qru`;{%ag1JA^e`{;P_{g^-sXQq%jewGq3C-Je+gI-_p>M>nfv-1oC1D)VYQ)kANy1p4CL@yr;8 zITGHShO^Y49ja=oE9m$dXC(kIr5FD-6-J>h*wPxcYqURRk}#%=*Zgl;6qy(hr33#R z7ID9sOGvd|3-j=_pA>^;?StVg5vd`IfVA+YORR3-6%-iRX{DnpYWNr;8udbRJ!tG9 zm2F=MI2r~ zxy$pJZ2z z5ADlk1k;)T3pp5(J^HdyH9iO6`(Km*j3;F&Q?~IJTaClnbN3bPJDO8`hWkjH!GMKc*;cE`iT8vmC*ZnGpx{bS0j05RqOa!)=YUk8H!GlBHK*YP4z=zCa49($*6Y)N4MuQt}d6$IYDy<&nJn)v%~oukilgqH!5>A;W_^3;4;gmMzd$ zIc&&rF>yH=zPZud-15NOyiBW?-{x%YBe@kLoT_Hbi0}gGBuaMgg~?M@^?p()OoG?< zogZ0}`+-LT$>5N_fY%>tkQN_8U(iz9%}Ry z(ZpaET%mKL6-Lco@hniaCy;Elj(l=a4L*?e6`zZp$`*eEuE-tI4LaJowZM_ibxQy1 zzX?vBl&Gg%=QAc}+6F<#+Pj@WsS{pxQc$w*>&zwY`}Mp}-ooU|5oC%ppUL8p0vm`K z%`lTBWgRz9XnAYI#PG`gHa{SIFC7>D!4t!0ixT%qT0B-uI)denCX)>&y)WxS0!h&E z!pTRQ10EFgb9|4Ly|{~I>4x}KOP&9>6^QA~8hAi}?@w+|T=aWf5S-bcvRU`n=F@l* z`=uU*x~F&@Yo^i-;@$~huslvwkB#;kW$~l@c+xX; z^s=`6orsf8UD&!ooD+EncHr727W-$mCTjv4-Fv=L`Vas@;c)xLR|TVxL}y<^n=OYM z)?yju+7izO@x}!Isra*Gn9Js5SV^%kfiu_h57r)X%Y;x-Wd8JB+wZZse>$ozw${&@ ziv9I8EgMoS{IZVwmK1FoJBR*&5h(U$$5*u73GA}7MCBctv?If~%fhdeS@%+uby|JT zPbSj-EB5@~&e9-9zD&YKcw4fM)eN(26GV~U*|HA@lGPQoZ{G(qUPXkfq3U=CW_1T^ z<=MA&E`H0HtvRke^oH7|qQgYL$Ef-+ZTKWDp6^g!&*$}ic^~!w+Kv+Hpjg4q&FPTQ zRoi!v$y@&M_G1^`0}qp&su9#Y>y$<3QGUYp^0s}+e9*K2XJ>Vx4NzoW$GP&krm53; zOdb0xcSBBA>y1qk!ZWW{HiM9%P9l@f6?GJq6SbhjdqcgF^?6WfNd_TY_`bE@f_W90 zE)T3-tavxGzTwjepReaNLo3nTXG#W?F%_!zn@+7JD^{`Yt!|_R;}242j=3%JNtKIf z8Vh{S&L4T9L9>hhToY5AE>B7xYwdm9xy??n4r>VW@U)4laQEq%yOxinPvt81DT&SV zqG(x$kA7?)X`5po%u|LhA!GoK)JudiKclbZBod^3j-u~5CIcmpp&=WfBk+5%R{oXX zn!yeR_aET6kLHf}+TH^s-$S&(j9rTVe&*COV%_ts+xOR6GG@M!)SUa@l^$D`6l(N+ z4CAr(@HXCizaoIA^~T$+LyAdBSr5-o_P^y0=Qm|RvHhNQJSSXcod1m9@7W}TNhTim zr0(7Maf95A1J@~^fKWW2w#jxeyJN;IZ|NK|p>r|b02`(_0@@>U!<;HW0*TEia%WDHS>ACysd6HFj?Z)Ix9FfZKm#)Z|;u zTn^w6060%+|!hB#t1}4_aAFY_J~h&<$X%XEm?&2xiOZ5emlFJcdP=>?q6VY zP`X^>jb-BceR~?EC9yA;CFE-q+|TtJTe0P_45`d|vry__qbj2q)LAR5zSBPb-yvZ=aK`*84C4JSqtsjV1yT>F(xmAR99$hKDNy zIEU)Osu7B38nUCo0km~USf%sqIh>eLvDX6UtrN@1H%c+QL=g!w2ZcLqzl$}=ZX$Nk z);MqLaM+)?qHN#KZM-k8e8WG0(R{6>jO3|O?Yb6dzoea&kC(ys2M;(` z2nCvCqfK2}y+DU0n?#c1tCQY+Aa(gJy`{Q*LIJ7n z=F2g6+KfD+HEfMKAqS`iuAY6)mOaNzTy~|~`}jk&HiyarC^1Af2IM|h6U{+rZ0`{v zUG+e&*T=D9pY}`mfPQ#RAD_uVF52s<-EA;Y)s6m>Ib{d1eT?<98P3uC5k2IA7asAHCZP>ODqCU|pd{X{EV_b9@nh$R z=!QlCbhOY2c6Nqh@5(Cjd9{0aVDGa>6U9X|7VO7; zIE*GJaAyi?7I43qqPM0t*t2NtG%?{ZUSqb{X<`V93D4B+e2uCQB@ceqpV*px5JPzg z-b?e?J0G9+5Gm2`)Na~XxTOZ=en#Z2od7v4YXL^Bp1rAP-(aIeiD+9YUsI$mZsQlX z|9yU@!IUk+URY;4@ge5ny=>I_&PTg_!_ERWC6f6r2Z<1Hw40oNE3qO84Lv0#XWlcW7mv@3SU~o%LsilKqUP-v*_BR z$A5~=+tYWxRjI(6JZqXZwWBKpLecCdn{zNTAfLOzqMB2X(KjdppL=--3D&og0orLk$ z>m0MwWDp*?fh=zo%#*y2e2>)^?X`d4viq#lP(8RW%QUbv($(~=YM?iw1-2gpTkJ=+ zB|DNP-rPF!f;CFc34-6`i~>}do}SDX)*p8j8fx@g#-E+DFj^DWU-`wOSM z*=xMd9szeTVBa#w`mEzz%l>yr-p^#WF!fInaKDzT_zirfRkM54JUI$ELOf3R4t5=+ z2tCTD38lu|^l5RwJ8f=al4{qFbCP)pKS&y;gMa>}v@C9J0kZ$r`!#b>&#Bh8C#zkJ z{dP|3Np79J0aGrfG@Uz?T9UnGCe*2!ENRj z5jDKxxhN5BVEfk-L^$6aIWw>Aa}p^b9aq60!G+j8Pi={>**uy0w7?>h#P6|uJ0~wC z>jp@>GGTrnJ}lop&;b|#mqxZ!^s`tH>@cRwCPVknym+E@d*%T_DGOfP9d4b=*;TZd!Bm4Ee#~r%d)?7}77qHN{Ql2w& zR(&m%`*_Vs7!E4tGS$rYuixEND}&N+)+wQKpO=Akb+I9D1l_L4wu|4Y)+-Znd3PV> z&>bIe)iP{?Slj+Nhuux!X_NP#^wj^&bu-{@0}ZWH1VFqW(r0S2-vG*#>IWT9dT z=8SmH$ZNbgsgsmK@s(oVnqfguOl8R`wh($EI2D(+)%HICD|v9B__x_stHr`S|N! za4KMYzvn$Z!;l9!NwP=}LYHU=3fZJ>g%#m*Rp0F|zqL)xJm@l2u;P3c=#EXqAZq(B z)QH46McIXUxiO_O5RZY`ci#-Mq0*2oBaG;tI9blvJGMa>)xJD!XwhR9-(@=pII^;P9paNfToetCK?89g+bmDB=-Vy zpL0+PKBHUGkk_8?9z@)vn41ltW#;2gb{HV1b67!-H?{YXl**Bh_K*`<}Zb^ar{>A$j#D13++mmwt)=1~oHN=wfUGmZ!M8^8LJP~Ez+mwbT>o*Ox=p%3o_+e)Q^ z|7Ruum-*uy-*uzi6}}VcS#fz#3fWy_y47iZ%FM=xR5DCM*I-tY7IptLE@|v-v7+0SjYYXbjIVN)ZQh@vsvrE3AEqO zJaNv~o2L0n(MEQum-olE3hFyBGSS*~4eX;h2udg0wlKm{QoW%n*Zl&w+fA zbAG>xi(>qJc0Kq7g4! z&PmF0nRc%o@8d3rI~jI=#vR(!i9MXxGJbyLwx+s=&KR*O{yCV)<|(b<#2fNg2w#`q z6_drC5)L>0eb#x5D@KYW=g<5N_X!7JU3_#%)J_Rj4~xA}MIl&qk{a?@v|BPN^KoZd zVqsO9TU33W2U4=_&sv4?DTK#dk{Y;L;cB!wEJ}`l#NkyYxIPnyMz{19BKKIR#pzgu z7@q6&P5d~7p*pehU&*NEN^UV9km={ze={SxBwrw@$}(9>28bv2MOeFde-No5vv)*5Ii86wQ~}ao&_X z`OQmP)bg~HD?X3?I5^`~QtH#a{j5PRJnse?U_HdHrlxK<>t!3k&`^{7D6RIIfKbjzK&57W*1|oueW;j{GbvH9qZrH;@4>EgQn4)$`WTU zYU)%01(g_zl+-gF$K8y}NgpWIeVGt#a}DB@tj1rpV+yOw`rG~s9D^e8YJoVw{Y7-%!Tn#QLVNgU)>hhTMgDk#N-RXBIGpTu#=yeLF;2ydtdG|>8ZWR^*I3Mqm$E9U9;yYz4aJ?aeeBW0TzE8s1@|_^0d-2hI9njw# zG(K~l;QYxy-%{(hJ<<%K-4U%0CzB3G6sI=!4QV{BUS0y%PmD~%w&q)dcl9<0qtraI z@lqaM1A=^tnnXp4dFNSS?N7!}*Yx4;?$S_n1mP_YMT>K1Kc*a0BU8AZWvvZjF&&p8 ziUDNPtdhIe7-WAbC5=TMM&(xRuMd0Pq>~KS4$#@;Vy}VT-!q5QbRCE??xWe=GGi!q z>Q5$_&)AXk-e0<*68l;^*~WaoXf5UuczjjTB;$yOqABDd*WDJwfDjlp0$Agp-4gPO za}kGm7^H0PRa0vR0>dw$x?QWsmNM*@viY``CADV%8fR&;%-O1IwM)F}TYGSBQ+iVw zj^N8u;pT?0G4eErg^$A;I{`8B1UazSYNdJ8JJVCZwhKE$N*p{-jmshYQN_mZ~tbWbdqCDi^Cu~&1$}C z(_ShJoY>!o8Y&l}DDHqN{ux0DF^@o3#}(-r|9*WCL(sKhJ-5WAMX|n9g(vG91TSP2 zX3f_%iSTwRsWa^LdU+DMgzuY$u5{epKx&R+Mt`wlx))L0-D#65u6tJtT{?X0x)4+b zWO(KF3US&Jx>1jlWKSPjU;I5ncxxiC5%^M1BW zx%@GxU(Q)cTL$I@u>{z&K0>(wG<#6_TIQp`seEVUEM$JC=+UFT&pk5rn|nb~+WR1t z+RrTmBp%%jFdiaGX4c!}Q<$ikP@^R5%j9&T0p%$KR*PGGH|rdh&c77Qyu{UE_XKB~ zt578w(($(Zu*2oi7pYt`L@*BH@5<&zTgMZz-|CJDqxnD?8e|xTpgHtdB(fLDS$p z>cmdLNnr_k`8|5asFN2imS6BbotLP)RYvQ14j{;0$qa*G^H_r{XxuDsD=V@m5@;vOnc*ovfashaH$=EVDEOU|1FI@o z3AVGc59{PUwLMWm2NE`B%S0#gPiKIF;T#cNw3&thN)OZZYoF;+`H>GBr!Abyj^(0- zj6B5%<|F)PDcWWVJsFRti;?l9TfC1u?@{sp!Cs59*d?=+w)7Dx&;^;Ya_N_>Px$S& zjg^Q1p*3!CR)xgyYi4dXYH1_4$s_7uWg0829T9gQ+{rI3#$09nB;fhpbZFzd>0QQ; zhh~zKB@@qgr1U*AYNWtGU~5g_jOw;(f0g8nca`Jauu@}su078*Wt@Pt(H6KZ0QC1~ z29e(`68)%T#%E|upe^9r(NJ?b4^N^lFe`UUY}^hz)8zohiM-$ zcTFO#&N{o14AnkpI14jYO-0NlVLe{r)%W=p_#_nj4${b@S$U z_EfvB-CY|~jqjIyrX)0m0A0G^Ot?RSSC-%Q3~1%jK>UTbw|riAk0UntBHwB_kwDl; z4xe!&U=d}8cH}a#&7e+P#gYZ~umcU%3=;wE>nOfsf`9L(zQvIlT-Xw&CK=Hn!=6ZS zh*O(s^=6WWLRcL3Zk7|~buMlHQnB$saWpRQ-8JQhcLgzS(@PM7RP`3P@ep2$D z3=+;f$cLYC*Bz#@7!y~6A_L^_B{F|Bb&o-TduK?pJWRJOyZ>p25z2jeydavm9O=gu zujuPzDkKWw?%Gw3t;OxocM{f^R++p{icRPLch&yRmQYnRKg<`sU$1q|w1~Key?vZ} z$1cc8b8^>$Zn;f;@>suT(6j3$bdKK7Gp!LNH#X)n6P0x`ff-3&b1)jEs2bHfSBmK^ zjPQHDvE)C1m)qSiSv%hh{eAkFJ-LP{-GWvd99Ro$;`3|%C}T*jQEICJpq3(Z-3Cl< zI~o0&qNR;h)T;-vA}L0plupK@(e$AhKO_GTI3gE+zgo0G4d)(3;3>mpLmXI2c=~{q zY7v%D)L|B!zPZ z%t%RC*1g4NSJwp;KVy)A&YJbGQT=BtK2u1D26M|KAgTRcrZ;P_Y+6sAf?3$8jYS1m z$XO|dSbkqNFrMDpyS_q=X;xU!y#2&F!7tpf?&MKxq8PxCS9ym)b^c>VFnMT#m^GcS zP7I0M>N1|ExGaMC+Zkl6nkS^>5{40lX7lvcM8iLes{2%B6>9!WN;o>nF~;&FuXiSv z{2A`-1g~F_`{t;b(MXSAfY;T24e`jQdH_j4w!d44<%s=HxIAi|HDDuzdzs8|ej$zG zlkiRAFUK7U_25=N!!nuKPMHFIJ5-&r5hx3OnDG&{=sCRS`{hkNGc+{?P!OMCP3_2| z*)fOjS+c7F_Z|kx6}py>-z@WSk4VzPheyl!<9ggs zsgPW=F8V$CITRd#@g+TadwY9#VJ-9b=v!YT(Se1IFrN+#U=AF^u5Dh*2tvN7#dR7g zN5prRA?)^RKI3$5U9AJW9jQBSE$IH!2HqLu?8tw9|NE>HJxk8LwAJ<&w0JQlQyCkN zP!)uzDr|t{B(bhkY$k%+;N7VU(%}woU^h+C<|6KA2!aVF(8)zK_vIZAZ|1ev@Y79d z?O$xn{8E$j39DL zPAV>=__=1iMDlwopR#MFr&=LLi@LrQ2a9(I}yVTZrBrRqcKB zF|<%;6iBEg7?tt&jH0OJ-EqA4Gb*AF$WUo`1H+;g05iiH{(OYq65j{@my~?v&@sjs z>HKqpJJ*p-r8xB6?S1T@S0EHHhY!S+{k?C-?Pn>}z+f;~?Ves`vI~rfV!3?JJBkul zLMLtXkV2>PO0*Yav2RV9))|;m26N_FQ}Swehgilvr&u?m3rH2zqhuTRMpjjK#9gGA zPc9F%D1G+#$*YaoqEk7O#&#?rMhH>04c0e7m8UBq<1zvq=9l-lruvfpU8%?r#?Yac zmIzQ@r*YNhrb1>VVT%{|S-Z_M#8`vxVPFuq@U8!(bJLPnMWcEw+_e6-<~FV7_uYJ= ztcv36mFAcr9y2sEa?ufET_(mh)@iLd#(_u)JiS>QuV_v=iMuF0^Li!d#QTwt!7Ncc zpwLD?!kwG4jI?h3F#_{nUCJ+lV!aO=7`s)>e=ICx-x(7~SYii6xzSpD^7#?*vdw_I zN}EvCX%TV6VLVwKJecbq`S7KubcAa_v;(w&cTmw-UCeZgV0*uRqnuA3+&mny6_Ta?4Cb&MT?BO#37)Ctv9RBDd}&rPfed^83y}#d1H;>Rs`-03O2*6;O6dRj;)`g@8z&yog1s>wfO}Wtwc=CTA=&M@p zTYuPfW=;lr##(-r#VDqAD>z5!|$RrUR z^bKBhyQM|i<}I4AT_)Uj;*fT!q!qqZFXRR}9QRX0`WJ=13j1pCK42)rc+6(tc8<@@ z@WAW^Xb)BAyO{UDk9dP(^a&i*ihw467-7IyKewIrHOGR#DK&0bc_qn0_17|87_Sk**Z z>Iy)Ff!u^szt27gGS1VJz~y`$^|XOcb`N>wBt|&yLTT2f^yarnlWyrxHw;^l!q=8u zp&d5dYJ8M4xnHOWI3vXFRe{L(qXT8~e3i_AbA9AP2)$aHM}Lbt9<#<9?}ImLQCpcr z*WXR^m3+<8a>I7;m=s$Ld_KB9b^H2iy3L8GDO?SoY~N`(+!A} zd*9ZKx4F@i-Ca>M;bExms9@BP9`->K#NR{OowuxK)N|dR0al%bxY(Oq5@h@h8Xz0* zuKPNc_t8gavc-zC?B>&3Oy=5}=W!Y2T71Z@GnyMJ%u=Alr!s7Ge59cKyUuOK)1LxP z(;{G2OtV~g9#O~>ofat!!wWS1`Lm@uX%*m}=9Ay|_de~29XA5c-FLXW^h7iL#;b<< zeugor{ zd+ruK+$M58Sa&eolsW27Nck$A7+`Sbsw)%o!c(5wgB$RZKi?G*dR@weczK{thCScI zm3g9wX~k7cz9Yx%Ph8{3mmLDCVN~a8%IH?E;^Q?WJ$?K5?dvh#GOWu!`1bI~mc5K^ zuCALw^>~(s{o~PR8nU*!kjL0c(8J~DC}h>}b|Er-AHA`L$Wtss)S~J-KB4~n-kR*v zi{WO+LlLVFi9NJ_$wy=J?>Xuqk>Ig`3%b)+;k67$rq#X1;P*~E>W54du+t6hKV-q_ ztA*jwAYc8yk&z>d?`0LeKH~Z1 zABp35!8CPLP+nWS*Jb$eVk8`A-}iBhUrYXO?jK|_SBu#J7-@CQHk#}LUYcF3FYEG| zG2Q=Rl_ zpLL?4&6Hof@0i*W!jLEQH3F<=9NHm7>}0{a$v@9*1I@QY84`s6r-6LF7q!#>d!x9k z(vs&;LT4&YFw(gII|D&n-#J_a07`q3;dT8IuVV0)G6Yb2E7v@Guo4*&9r>o^g{Bt0 ziljMaI{m#xl&B`r_c)FUZd~7&ws$CVm)K*O+!^&-D9ycZOU#JU<4T?tj6^8dO`E?> z)}woFGkXG=Pho#c*{Zk$I0AQew9D^yf6atInfLfI2Kp(dKDXZnBezPT|fJv zNKZ~t>g67*Lbm+75|G0DYKr&q3ymDf8=phPRxout^p<@^i#!t6Ut(9nfE)w&=eMkX z;OZTk#wYhx8XE4xI46K{ossY80bfcyjAiqZ9Bl)IOE2G82cM%NG=L}v79@Dj1gki> z8%&j%9_sg=b&esRUUFZ%t7XUtJsc8aXZv@HrrLO)z47!!wBf#N=RS9?L3&XpV>~UV ztn?$o{c#~#I}5uohaN9+>PGKcXlZ9nXDTg|gVjs@o7g&a;<60+cA$JnCU439&g~C#uU6smTR`=zi+nQap_RWz4*tB#QH1jyUuJvn z8EuO339GyCka&~Wllyox)NURb;hiCWhp&N52G=uc68rTnJ0_!t#-y-hcDZ+|^qCl4q`r>QI)?(ocD%Gy*{zEm}~kONdh8T73)tY zo46Yn&?k2N|AJTWI}(%PqP|ZXW=e@{0a`z=32s)6Zlf&ct`c9)3_`EsAcHB3f%*6X z-V$!dKjUr+xNFr4y+GgP{8e=TGBw1y3cS z;r{#d`^$dBVP4Zkz2hlRM|Ilr%)9oTvOi?)FatUy`0t6aNgRnSrKUCF5&T%^ikPh& z!FIaNA*B$O`z?k8&rp}HoK9g)*L&&t4)MdNbbl`6Pbg+h%NpP1zQ*s#wD*b@O!PcP z4JH-d-E$4)~#_#AgzDG`OfGNP$NpeMkYo?i8}M54?sOYg~?qgjiuP zjyl5$+^~Sglyil05Fh951cM}eY@^juW$ zv7C5-V~<0?d~$jHza6jz23W5%I%aT6?+cB2Q8IR6#>q!0pvyr57#q2L+$8q#3Wdy_ z>!btk$;}zV59@A>VY9MUgKA}@S+mJnbDSx-D(DKNh#MjVqX@#StB-J~;vVVEqMs{{pjw6~LZUe!q~5 z?>=jY#hD?67$^N8fW0(QZeH6ELd8Z^l?m8ukvcli=V_4z+y8 zY^hJ4qsJiq;D0&lpqz-^U?Xq$-FXY`;+ah(G30zc1p(V8rC-zjd*-1y=)Hq*9~vP( zxu0Jwq~hVs5+7sWN02K;(QF*L((9Papv|08J!2W|!!g5b!z1-}Th@Ie-^KX-MC1I) z!O1&wioQ0bPy_>MfZ23)E+yWF*K2d+6mUc@GpenhT}vr(Y3yUk@ZByj47DcP@=Mb=$`TXoFpSSb#>A03S}%cX=YWl+4t$kj^Mkz47cx~dUFT}jIsVUZnz4U)itEbdqcY+L;DEw z>s*d>D(Tp-Nw?S&`HsYRi{2*KYhC-A5NiGvi=qN@Q(+$1p^7;=)Z9v*LVuDa=Iyur zRa)3wYyLUjE~YLo+MBDDaROc6s|tAro#XiHUclncJ98+A=QY$ zmwse>QyV|)`y)qPmHd%yW*n>^A4&tcz@LgliPz(Uh>Z0`5@#n5@V>CIVc4Bfe3kL-dXatJhdnuky$brOxO6DQ~&MnkHD~>+o3t zwdkIE*A)1VrSo@p8&)qF&oQXD(m%&&iP=KQ{Sl5Rzbp7}zq)D^MBA7e<%4*X)BK5d z6kjZ3Rxsj^w>`bpKDT9wp4%`jcw18s9}O+xf8SSyU{uQ3KAn*6ss!{&;?<}p_Xi~g zO0CiVPqg#>`2EPnEwmgRZ@zc{Rm8H5Tg`@wc9aG{AzeJ3FQN=B2ClzHpWu*d88b)7 z*!@~7)ip4g7!Jk0Hu9kUyV6XUXn+^bA$_=r5mnFBrQU2WRTEC5MWUYy7R15O5P*#} z}2wV1r z-}+4aSU^eWp=tB|`k0?j{v4m?A}=W$9;bg+yNzqwr4Vk@l>w=0h1d*4 zt0q!iFc=>lh$`N=;=Pyd*NLupEfD%TEI`@D`gDcH8+}C`W4cVnSt+K2v6`7mjd>7$ zX8z94>x7A$Lv=_r_ZkeL!?G!5z!NaCg_OY&uBBt%=pVMRM}FR;(68}P3Ns~}c^^K4 zfNDTJsMCpAchAc}(!D0^^@`)lVwb(jq`L`}ee&^WTPrB9qEm7l1=X72JYnJ^V=o|f zVK=hpcV_X0@SYJLU!KX9HHk13ZOHc{z0aCZ=otUMJ;-^+=dciDkY#*!_;3`^V*v0B zlYwURX0nsu7x`SCl99$RzvCl(t{Lp^v%t+tk7CE}pmiR9)SgFTFo>(!ZKOmc%^r0W z9epo=9Cz$}@a|H@mg2g9<}Pp=Dhx8DoUx7$O0{ zKT%K2gRc1=);Vs(t0=xJhsLkMZ7Eyk!w#cs%4h-rL1%lvw!0-_zYxwCnm(fvwbZY( z-a_=XwrUUe-6s@<36#fOIXTMnFDQUzT1Bh zcJpa;IQ+PbqnciZF~BSI^9)vhiWlMr>T0g)f|PAJH%ztknSZID^pA~n+}I!0*mhp? zR*kv=d@8if&EYWl>%LKzz z^X;XojwqD+*so$4|64l4?f(?OMBXzFhIvSt@R^vw8PdJ=I=a~ zMvXwr>lAHcMjAk`5Lsw_`O4^nxtDe2 zkBI71!O6aeRkAasnWQ!3l!S+7eO)Y;gX4F~bZZJ(C=peI|8srFe|?1KP1QE4P`K^# zwq(fX@QL%xX0X#*sI6l(`KhPBCiR1mZAd|2NC%3ZaGg`;p}0PgEk%0Mg|KnsOJ_cN z$_PO1xQf=*+P|G$YB5-X2|>Abzubu6+%s(U>I2^A*nS~=HGnbAKoC9jGMz-{QjV0% zY1@W4Rt;|oZ%`Oc#SUe`$^Y>Ep5iDDg>Q62#Y0eW-((LVOmvUs2`3k8x&5XU&ujWh z&dZE)pFK)I58;9&tor~oUEp*u$TjtUOGq>qtvz~KIbXcMBXy z>Z*XcyPfjPDtd<7lc(n+-zd|5C7pr$_H_fvoknIs?+B3$0OwhRPZLr1EIzOJ)wPo^ zlw1ROFXLhENIiy+`J6KYaFy71d20C)9F5h}#mFcn07fOKm7(0;-4rbw#d4|gejmQx z^eL`2V}m`P9#rfl8YHUUOk%0TxQxqk8z$TpOY+LCZA!2cain0n$kK=e8$*sgkwklX z<6Yw3cQbU@q}8McJMLc`6PLhT_zL1%-e+ykkX7&8QxddQk)OFW7EgG^DbWEo}VeJ6;|m3eP(L28<$ZlY~xj^bgI7q_?2}1DK!8zNwxCa zRRJ_6dc-Sd?f2;ecQbJQo{&a?bL`nZxZyZ#Z;}Em>0x#QXb9!_RIQk#6n6tiy7K~D zsq4bLe0SFCn|1P}{l7})-G{h9yX5O|{hpua&yH_LWzo;jz`hUrdOv3^%p~YD-b{wO z5YfeFZ(L+N*?ue)d_rp?!8H|;C1%1S? z?!Q}_+@^9DzM^P`d}ZSxT1h7OGFejV_)5SHxfNaLO@lJ$>B|6!z`Ycu;2LUS>MYBo zI62hFW3(tW0knf2bbUq`}Y(x8PMy0dz2i_*DD}i(vJiXWWOJobS_~ zgNes~$!uusNa4i=fIr(crk~u&j4dhCtMi~XYhX4QV@xBvFXV@u!`wVvK@2s?(K=#I z6{nspx^S%~PWVx_E3^+GHj#$^@G_j-8NqY&rQp6_3pTmJuTMe_q2G^ie8%$XHiJE@ zRhAK)AWS_hOn7bon=H2~ZlkV|V{lB$VS_AXxVsqD@c2G|P_mcB z#ddfdx9+vol=tWa0LiHWQ#~P&ov)Ru#>Em}#`d*Kjo(Q+hOo_}$|oJpI*xH(9s*g0 zt$yYcR}g?AdwW5jw-f!v*0pDcf~8)B#lO#wu)LV9*fuYs~=>q3Hy=#bjU{8CI~hecouVlS&>R7;_~gax3HrFN?#mCB3n~t30E> z0aB8H;?={Jbn0ic?qx9PjgP)@7`rKVcJc0hXaqud1M&&qf`bvAg7( z&z~!!`^xr=@m;~b<_pR!2-d8$gj6F|ORmTQ!hfGC86k z*I0W#19XCbPvrWu3kSrp_FFoUepM8HT2NzR)BSzgNJ|JRVl0ePm%M1>O&+z6>y%M0 zCp0F7#89_z>V(ngA!3n9Fk&8}B_6D6?I>erXSGgPq4tuyGd=Ax+%3M4R5S+iYi9a5 zebDGT%ffH3;EsrAWPbf#Ib+}RfHAir1FU{(jBaXf`FtP?#`8uJXlMnCbqoVa0P%-)W8Loffh!gvZW(s zl6P%o{AdSDZF`44d`SKcw^U-qVX8Sj&6cE8(exQ@*OhJzU^o2lPhb3})V901O2TvP zWooi<*7%;Y+*ueqv|~#Sjv3}W7%q*g9^31cLNj0CRIq&}b&!6D_V&q< z-Z`;j$>IckZ_b<_u7WL$c#mwLoF$7eg%5emw_Ez%F{nLwMPf;g-_!OaR^s!Nske^q z@5(0S_~a#0%PK>tD6XMq*iS;ht()yL_&r`jDT4Rblm|4gt>t)fkG>$DbrG$|k-iN4 z59VNQiNoaVq0ORLW3O_J)y)BbwwJUI|LuFMQ*(drSxC-UHf9)s z!>rk(x#ARUwLn)Hb$lWA_qJg)t2ceoX%j&Sji_~ zAJ-?vL!hB~5r;Rkm*L*VMQpiWKWw}gv9*!ddtJPtM9pQSZYu0K4}*-VOaX7XzY#mh5EDe*Cga^;K#6*DF;Z3 zRN;OtrZF-Fre0GihwzD)_%s2|;0gSHhK0slQwPpmh1(F$idVJMW`kL#02cC5_4Vk@}Js`xWAmvZ>T? zFZ}-a#twY?Y0TIdV?3zF_&d87UR;6E6ugr#xWtq*y^JT%k8c97b1qr(atYE=gJw|- zqKu~UsX*qS11Ef+d>nusN6_zE-22?-NJVI5>-ZB;dU9krpG;?F(4#0m;^%g)cwW*BW{TBBH|uCNQ6n(_*@I`HoLw@Weu) z5LmJrsW+sEVWJ&~ay(WA&ZfbM)^qUJM#x(v@0Xz}sem^9c!or6pg(@VH z-prX2@UEk?DjB=2>-*xjH@DI}Bvr@uyb|jrJ2zH+&%|1ZKOih7=?y#10P@N~!IT^- z$j!Ep)zMJH8185>Nrpo$_^h(ZmbMVi#%8kggr~med#rC)^hIpn<*HStS;Qn3JHPzU zpNaOCwV`^=Q+2A_yKvPtl7S_|mSgld=MbNZG2=IdmOF6*V#|Fsj1=6vUoO;R6VD=%8W&PI(BC-oMX2C8uFDFKl})yc)eA(`?{+ zV{H6u6cFT$fs!#C*U+Ug;KIaC41-J4SK|ua3d=1!vOQrSszYhE)|D&9lxQ9^^yg3h z)c$8jZ4bQ6h$`G+eGG z>`)T|IEqHFqM;$>>h;pCxDaEg!RGHl2+y->ww}4RZY^i9B!dl~BOWv#N8iEa47WL# z27T-HcZP3oR^L)am47iWG4~5hD}Ysi33}n~){wB?`vFIPd!u|L$GGqDregXmlMJcv z;L(ZJJ|t&t>>c;*9Gc#zE)bt(kGQw|CnAbhS1AK%E{B!$^all|?7wH9s2I&@#6!q* z{fm%9ThiEgQtvTwB{D$DyEymK(G2ehzNb~rp925))$GELx1-Q()8%jrn@N1z=gp3G z)8{M5jVeE6yrj7IXRPHdaw@$hf1up7-=~klwVT7A#s|S3WQbZ^Av(wnqr6mdj>*ZK z6lYcyOAeyzM9Z^YAej9qQR0rPHi2(wEulz!8n&!)-W-^xU8l_51+acvo<_-qxt;~% z#Z2DZe(ZkV0(UR)`Qkb4x=|R7d{tJd4(+)9?x-tv`ii?bL&s~uk24^Ov_L;$opot# zZ|)!&-;7g+4oFVHzn4;#FroCN|P}*gx}A?Fc0Bm8SIa^B1$wjdfsJtnEXbM@)sippr0(w z2d|_OZl|?q`a9A$ng`iR-^#%$!HHf-@wcB2Uj528 zprUT@^E5S_m^yRpy4gF~O-2B0la`1yJ-mFzJ+|l4cuCpZK!@3?cR=5CIq!!mUDpqp zP=VetdeUA|Y}{Uipr5xq&-Dj2gm*a(dCs+z$%Ls#kr)xp54_bi%GWt`L-Mc>oywwH z8p)%w0PU3?<`@UDMqwD+SMT6%lwoRuAev&9xA9TB2`~BR`$kje#4`gn_}i9q<}T#6 z`ab+mqr&Y|aQFU<^k>Z}DP?!mPd@*uva@~kIx=ZE7=-A6$71T?b7op}@2^8R2^YUM zjx+TrLpNR6`n|j_=CD5phx<2%EeMj$!1v(&zMc1RkI}I0#a&^GOrRz2(ws<(4=l~F zjkmHTI^F^PHuH)7ryJX_mUtH+5zVP+p=Ni2!rIReq;507I%dGc-V4_<3h4W={7Oex z&QRsW4Dt?vYB}UDAJGV z#RQS5raq-EHcU8TrK5n((S8U3*4UtQz0(2BIM(NP1&^}M4Q>8{Hu@gzS8=jCaQYq8-npFKW%5RqQ2cN8W)m2A+qf`j z!8kQ2#cwKR=u`bj(3(q}_OSSxJ!^k+w{PWDq9yxY4oOPV{axTkm*t*a825*Df76iB zeBnzX9)^t%%`1l)kL%w!8)>?aB0wh~gv5t)$%X1Thfr?ePma&Q1e5cG+k3ri5oJ2i zOzrl+pnER{2jR#G@_o<8*r><;KyPx-rukAj=`pJBX1F=l*DQx-Hj5LX(JHO5uAbO5 zpqS%XN1Ta!l8AnmwPtd3XFc zxm;5GvP>RiCi`NBD=2Z9!)9^rWsstb!Z*h=6x?tO-o7UBQ0hH8y4fC)je-I{t?OM$ zscSUM%%*Z}0(!n61Oke-0?b^3$_5ugV%Xm#N&KJp;kP;YC83;mV%l*a)Z(0p5!gFb z$`zRhp;fcbSA6GhBER+ZzsAp!FK*D*g1cXDXC+H7o_6jGT1N})wmM5l8TU{_W7;f7 z+g0BC8dIF9H-@F};yqXrHCPFaOr3t8b21CBh{739kM99m2_&SM#P~8l5Ezfi%5Grf zGgR%n`=3_Uq`y2qHziHw=gsxhK}Hqu z6wj8Fm;`0&)L}OIu|y*slEMG1OKl7<bpe zeVK-FD`{E?cjlE~+Idc+yo8Z&u|86R=Id@~@;#@=+9J|-!DGg<$f82xV=qj?=DCwP${k6nTQ1JymG2r1Y9s7L`64{ihD1*m>rmtBxW7T zojL}@GMo(I%y%fXk#w{?L`2(YOaEcJk{shk-%PaL=5w5ZNV-+xf{e#SdlJeRm$l+c zq;fW7Ec=c-wb@DPo-+*`&2?^u-^V`^1l7Elu&@gnXBk#DtS-=GxAF76(ro55)SPz7 zkV|F*j%JLqe6*Id6W~tx9~QsRroqY^hUdz$;~!koGN9MLyQ@=JRM^!eJz>z7jd`I)`5`LA((>-c)CF z^V{(X*;1qRGYN%H44v;kPm_}WeNJ~GHG*^R`iAGoq2u@Pzl1>eCs*BRp3%rQ{#3^> z5diBl-7sF^EfXRAeKn~dxH~P)YJ|(Kk_qOXGcv?F_>^5cz3$d+bZyy>e}6{#tuvrn z{C+0a>rww*KJuY_<=iSo7Eg@g=)GOv6YgQ3fHsdJR;gh~=2d41JI@~PNaemAZ;|OR zb=95|H6$?5UCU$UQ;ZkEEnQ(YPr(n1sf_z;S8J4GLXa-^RHR&O2Gjp@QWz$naQ!a} zesI5*=6tW$MwCiCx$7#4SJ#CkrF~%T5xje}R`@;NeqN`VW62$*1bimP(H z)h*>|%>9}8tIrYJJs%{RZNq@^si8IC3XOSK zxy82bGZvQjGiLA0r0}{h$Gjm6x41A0zNW@Ud^0>JxBmFyyOxd1ypuJTA^#1QDLiIO zNAY_}VU(Hgv(Gt_&xF=@oe7sIF!HmrWuL5L3{h8xvuCm;_Hsr2lb5dh;Sj>sM3BIw zMPz$<$ffv8^}01VWbBh(;v5#Jg50TvnUh_}tUOPK6snmm>3~QE zC++{rncRlXk_GW^zDsLrS1(aVkAG5RJ;dcKW;H{bsWsh%3>NRv_)KM#b;&|aYv&!Q_?tsp1p8Rr|= z#V+hJxLnuQjo{evUZy^kX#U|OXnlsdP^Q#zEidaeuiHIhp5dl=5TK; zN!N-uz5e{6_oVSB={mO6`?PV!zAKEt)`;-!jPIuMIdXh{(}XQK%a*s1mtPj_Wq`N6 zY$pVC_7X@g;}S%d&X9Y3jaYD8)u{|~hMR4S*r5S?E@JF|aeuDs_weToSv8|Emxk#+ z_K7wto0Dd>=P08^OPphemM1znija+l$QPQ?(*WpGop5kwm)vqjPn89~w|Yi=c?pQfs`& zbqa@F9^sFwH37@_WC54+bxm{!f^;EGAgO;7r)F$Ab`-uBPMjFSxFdE%zOtr@SUXjZh}_i0BM7uc|AS@-b-G++|io1(pk z{ccFdAUEj@5A)JfuN!BKD?Y0pki^2XuxG`Xd2#|fw=u-7vf#*aysm+^JjB!9Ncg># z_=7ssuVYPkzU>MUY1tE*3O|e@nskeI=iXG~>r;NOfddv(Cr{vI%uv z_Sxd!?Gv&LV;TmR=s0ZX?}ksRe?~3fhW6X8r5YyJ`B*z3ZHf2c4&+PPtGXbGYOI_2iZ|CwE)g$&gO)#ipqoz zumcU;M>0Q|bb#4W7{-x<3&A=w0JZ{PL1zAP2f`>MfnnTH{XV~0?Y#~OBqpMQ@6+C- zdp1ogX0v*qA0B`D_v3Uugoi#a2A#(lI#ZIg>@ z$X=Z->YU}ns@cq;1fv8G%r*CihDJLWL|>EhtA&Q5g-GOOg*#7~vqp%mW(i>;l-9Yx z(uQ1)60s^eFBE`U3shC>lf2%7f1)TFeXqF)WhcZRV9n$7{E{G)i*64M*Jz~gw7>j{3e_ zrDY~u1hOKzV+fHYGXRJvb&ZpkDGM+_p49|^O<Ra0 z(v!}g{ED1He~&+=wiB*h2-?KE$0Xs($w4Q=j*?Bi%voqEw7i*}c)68a2{BFE(jj%! z2zk3r_H;YjI|$vcf_Tv3`g)}K9p3sG^J~o<(7aq#vwz;%BH^IxP&@xtjuQ$8j#!F$ z0m9e4?zs@BS_%r}5V&Te$a{Nisi`YS>I|#K9sWMEXfDs-$Hie7*5|2JmcUe9kK~_0 zi-Vgt6YB7q;_Z`fSVlw(PfhQAjhnOUcYk_>dqPph8uQG~^BUOaQV8z_hy^;L;jt8F zc~{-&SPW^BD{OLseLQ8(hQvivsfEl~rg+bKVP_ z{!?`!G|Rio59FNi^2?a~%p-EzgP6R!G}vz1+Q=(74|g_~oxz0%6?nt~|M&Fc0-x2E z@rylY$DV~vqo;qD+O%L?F;!{b8?J9h9y9lHa=cV?=oU-!jx8{O2J%y0?;&fu_O*Z- z;BU#Wx!?gcq~{ol=c|r3!Vw1^LV%Kc1~=_x)Q8V+EInZlHZq6;17-$oMvwoGwL8m| zB@2=(JE1_p`eyzUD_>jfMiMOYk3c0*ne=$q-261EqS7omv%Syfy|9v~x`ytlF*`~j zs}fh!nK=wFmKB6Ebr)UQ&gB0;)&g)G-g~msuc2L_9>+!3p8j6u#yX#E_#2z+e;^eC z4l=gy**n6k?Hv;k;*Y!StiAyF$2SAz6GgM$9Ko~i;mOF$SKGqhdLnB7dBJo&;{E@Y z$(l@o1f}x1p;`uD24$FC^!w*FX;6GgDOJc!K93wg(UEn=_1&)Tn9yRwKBn?BH`C1G7~do207X1q@cTWX2hYFp1o6L?*g)UIPScj% z+V6PC^)1DMHObF za+EfknyQY2si(D$irhjQHa5Y+F?DZdk7249Khd`$u`)>9kK;>6Lfa{!lk#>gKi-@^ zF~pM_5@!689(${Y{T{w65N|uJ$o(e)Eo6uTO~1)pllU%cWd z$$pO72_jxDA#T%cX2V=CFe7qH>HLk^wK_WE`PDc{rb5*g_=kvY;IrmTM5FLM{W~`t z=s<0A%ov=v0QZ#QLwo$F ztmv09TxAMIQtDhNMG>#z!OhwcE!Ui=7?!0);k>J~w$No;BHCepBX$Cm`m z!G7{Z<8QskoM>oJpy97%32tUWsC5R&@(TSu80T9xS+le)!#GN`jP?X4&4&)r3-?j2 z`^rS%knCz4&&W#@?a9)G`NLxHynau=*2z6cyBtmP0D%yg_!Sb#lWO_}c_Zu^Q86N! zoErZ`E$ip$X}r~NabM0!oHitDR=<4;T+lOK5d!hl_kUcV0P3jGi1R&%k$CdJ=aJaYw@0+3kwAIkoSW3LZTFj3|(l&q3Box@L4y zGME^Mb*u^G3KaysA78Dy=6POT-jc--o5QCpW?|8(o)y8(l0KnecpCx6p<`IIA5rMRaN5Ha9 z)`XVNN^~YvUzIPdA4c*UdWeB&NTLXnk70_BoFCd8mUbI*r)ht@Tth;$VR*aXQ-Zo9 zKK{~(?Z5qcP7lrNoZIX@e-rBSl&tudfnoWx_+!>&Zc@hw|1^KaKnNB)1%j+H=9lju zqI*0QX>Ve(@|mE*v@iSFWw+;n9pO}hl}W&s0*SxQxdi%xQOZ`#1?&DEK2odjnK@VS zgRdHGl5YykAp9HdFTPIL-`QVGOv6sQMz9V07eP8L$ zj-g@mUIXwy$>m0$4Hxj@jo71*XqVBP_7Z}8M(E~M)XC=qLuXFt$DcF^tTe4Ih+LJx zk>C|(Seo?hZ@Uid2z!IUNQPy#*=VGtD}2JJLho~0mD+QBlMt4hzTG?Ss66H88%~s; zF%(O@tr~OG(}er3wu9IDC{vAIcJY7eYB-YpugKyEu@D!x4vonqs(<68b<(bI{sEpl z*NGuGI^k8|w{m?b3g=|H)FF*8o|<4&9^W6kC$3OQgzxxL<5-s*Q)cW31(mIQ^|JbB zRK#?0&lh@$JpeXi{`xFq6Hadn+r$z&J}Le0(T3v4felLXj)^0Kk1e3!I%g6hehYnX z?KMrwRY171ruF5=?}Wmw=!;!5QRQvt8{o!5_Zm~9Z1eAH;s*v>mpidJzH%uD z((^@%xC9vflczR*Ba0-Hf~sNpt=@Df`abS`ie@aiRiM%L_+75rPqrVNHH@>c{+x=YflhtK`hp$b z(`LN`-+xbZl2-?{W+jHGf0z1H0~J0L`9D)rn9EXjRxr~f7?g3A)5#EoR2@0 z?YJmT$T+vcrW&NM=xEyihs>fS@u1S9@~`vjJY^PQ84GzD_$IZqMa22wBRF8Sz{6}; zu-^B)Vk8dgyB3{I?o6u6}$w+%o4hSj0Uqxxbe^iWqKzOIpw&1SBOC z$j)wZ$XgTEbm9R%Z7o>tfC=XL$DqTwLPz;CRVW=9d+fXSlX893Sfz?jPpLv==_1Izoqdvt()y8@Zh&vz=3;KNYb@34G*m=fC*_ zf<87gkndpJMu&5hZL9j--jNxxWYzB{X8dOq3Y3DxT60%6r-5-6m}bio-rQq@Nwzcq zfVt?s<{J^gacK>4IuO-!hV!YW89m(ziHR;HW01t~%(7;rx|r8mHOF5qPNv(z#u>7Ti$2F74_^@02s_)~FYr5KktIyA^{!QMa|kB=*0Pr=2J#xJ##5e7Sx(nX z6nr_HN4%&bLm+sKjRpEUQ+wwR{bu<~?eU%J+qf_2gb8uqzuZ={F>~5$n5HQ&_gq0t zgQ~7ru1K9hH&q9~ro=@*%Q~x zTpwFbM%-GE2^|FgRgT*jmGX>T&*8Wym^)))gI=!5s>G4Ki=Ga4`Df?>HhM_U6wi9-ksbe>Vxc%k0R~^09jMRz7`W ztQ)QKdkz;h{lCKAv0^vyqvr*uLY-%st^MrY2%2XYEb_WFBe-p9V%V%(D&Fq3IBnXA*IEx)4_;duEBg^NPCEc5;yeG!`e_;4T}H#DaJy0cAfw8zAS`^Q6N z_jgQ=I|qX-_b!w*N`?6)UpN^xA8~V)PQD~qm}oKO-8<2aZws17nhDo+q_D52Lb{`n zLg<;ADW6$o?u>eL}I+%xbJkRawMezNs;q(CqAk`Cdc+bjF8@gQv)0dOA;_HX+y zST_gg@S3aj0fQC|GL*0EdJ2W&)Q@8)--bm+Is*Z&-J zgAYIA!7|9!6XHIoxpMfIl$PRNM;&X z`Vpl2Z8*3Sk6HE}U*tjMJu2zr^?bDOSMbOI!rI>1PLHDz2cRe}^CNehz}m$mU9&%Eg11DCPp=PuUjdzh?$8Td`;FH{M%^ps3HR8hv6uI%-iRY`kby z|CC~1x@itygQKkJ5q@_QWPZ#Ha*@(XE*`CxAB&NimKttmfUE$3Au`4W^#x3eR)J*Z ze#uiIk~8`qXd*=1d7DPA+cU?*9__=-gk!eMdtjkMr&s-nl9* zH*?DZdM4k@5pU-8=H}hW$+W$1@Ba8_+;L1C$>2Bud-5h|_6g73Gp7av768Xu1mO4V z4Nmsd*j(Po_p4+Y2*c!v^ZR~w0LN-{(Qy>=FS&m94o zlIksqZ?-rqu{Ef1*bWuLbzbFO{@#%RE8tYKbc>Wrh4wvqn_nA(^Vu*rx;a}~kzpMg z)24zLfH&(h1>`RCktwi1hg#6izVhC|S3cuq9>Y22PG47z!RNl(ycwN0P6Hp^4XESz zX1V~)>q_2|P)Uv}#Sks~{(JaP->q=sG3i-91@Ef1a7WKa1pKmk0oRUT&gnw=r{Eq2 zF~;UL{s+$p`1Vl)Qdw-1+bXNw`@Y~OM}W`JRw0B*=@Ib1RMeV|u; z&eqrrcgjgLhFtDRzdNhxZql=m;ylT=X{jh3tRZmM+_vWXD9ZO(z?jv^Rw*YaT;sQ% zs&AK%HYAS|L!>@v{SSCdg+tN@40o$6$K2hU+iME_$qjQL^Fcz%oP5sTG9OghF@mHIY=i=z&~aP{TF~FJ z{2qQnV$On?BynDm{MBpv0{&y?IxU(tiYmgTtNxlx8pQD7Rr*g3fs3=lZ0^2?Ol@?l z<prIzJ^EU7Q~+!WwvSi}yo;-6Ctngg4wqFYmtTD>9?{${Bo!Q3S8aV6*5asO2q z;Br;Yi1fx@#SrXr)5I1R;kSyXn7BC&@!4c^`A`iQQgNm@9-TwlMdC}};hso3BsE0b z^r(<_?ngr442*vFbHt6K-!9tUw*I~M;U=+m*b(4+)QV^Z;tv7r`i%2D-j{g*1MV?M zt&g)m6vv((GlkBZI;H?c z{2K8vLYc+O zXyi!6a|faN?IxA*{h#B!0Y20nUdUVPi-hIG{v^9@;&~P-cP5=2OX(q5u_t-IJsuS* zUEVQZ0DbE~a65EMTc0K6RnTrB zm9IHg+@+H4nxjIKVm@GSZnr8mexDf!Ddp?XNGrN2y-s4zYx%Q8C!{@4&?Igu05gjm_1#~e8KNc>qQ3sc>LTW7e1Em_ zm!)CjVL*tR8*6uD;62{gI+hlmDFb=UN3^rUND54_qK}*30iJh`7`V+(b>yDFl^8O6 z@$cd1=sp9y$c)~xU{v{_^w*hH@0!Y)&E3nCseB7~5R|dCf8lHunG!>;kDpDm!+F>! zu#1WO^UVEz8@bD#YX<*z3RB(OONfIp?pRtA&|I$?0s#^t^z|9%WW1PbN^#*CGl1h- z_#!szIY7%L>VeLFmb&JQ^m!3}h+|%id{RD@ z+V49WNMC>Uz`spmz;k9?T!(i=ixHgR#_2OGzCodMWaV*#j(k}`8b{uIW{VmKWZ1)^ zxik_+&KCUd0RJ9-uqg`GtjgJX&pKQ)SXKp2?2DtCevDbrxUXRIUU_S56ZA{N%dh%~ zQT0#r+%214X%1Z#XhNhvY^pyr1`!^!x^jz)lm7SSs|wwUGy9zV7K?~Qh9cjyztiH0 zHdsvq&Y@+Vgli2fw)vx|GKd_}4F>0P8SC%z9M~&+5zTU7KgaM;z~aZQ%AKy(dg7T$ zpL_9ve80bYq@swr*>>=_#wF{OC}k`EeMh|c>o%?^NeBT+z10`vPI6|6Z&WrWKyPy1 z1KU%WU=`aHJqx*G`4Er2z6sod3I@4JgpV&H*db%fOg%Af}wv4 z{1%mcjGYikav@g)fZw|x2ZN9Sa?xvs=6nl}r(8dPo{!*29+5EK(cblg&3|5YVBv5@>zSvx#np3nMKL-rOte0&+JS%a*(BmHHe zqbM~~;+DDm6~CDqXHK3Z{99?qCzl%T@%SYe<#bt>tK_w zdd_R?XG^~z`NyXo-}J4rSHkaI)RJnnpuXiw1X`#}v)4Em6A_lGnWMd;c%07Lt=8i^ zbJNsQY!qMlUq@uGSCj#Ydnr7=PE>!|c)s=9-^0Iyzpl{zdHb0lokX-KcqmPo^B!4GIZqr`8rUy$d5xQ(36hs2 zt86B)>sD55Nb)ry2ulhCCgvZb1>e#I%@em%#S-QlqdA(P>Y2y&d5%eJ39pa^Di`v< zDN?D0&+KM7fHuiH(PFOMI8b8?bb^LOB{VK=tO1fc8 zW8*h9U~1aki^!bpd+;)!{}$5m`2_l=TdC<8At(d90Y0K7^7)m~ZnH z-~=F3;oS%|R^$6uu@ATI46)%QDv_m~$YFp11wnt*U1X$%w#L+Lq>m2(l1^(|b1WEr zrNU7)^aag3aS(^!Ng@}HyHD*Y7qv@!N4#sI?qc6vabt^z7JeW;;>tm`HEzdW0W-<% zHr=S^eB+97uX9O5zRhF?#Ph@pUzYLCvc}?<-^=(sO&Ez>Rg<-<0K8e=#H2no;p#*3 zv5i;D1eNivL!^@gkSV`EdGXj$^;|uBNQh;C(esC)j$_vzt9eVCHFmv<-`@q9Ma?-x z*jEQazq5VEq{>2(zZLYLhr3(8+g_|#9K41(^h{Sjmzfy_<@MIQPi=`+x|B@lWQM_* zDGGuhfV!W|dJDv4Z5>62Un@piX$%0Z#zFiBScTZ`|0 zdYYjsMG@gL(253n!wa@abdJz-P~~gON&=dl$ncJv&@!{GEerS0;X!xJA$l?VO42$= z9#>F8Ip=H8_3N5Yvatg+bQj3tu3%JeVZcyDjMBW(vj08#I_KX**$`v)uVAOAB#P22 zgrRDIm&hxlAzurnj9dK{xerrK?nB+P`% z>9^ujY#JLq8A=tY_|F6j>ed1B$Bc==?iZN>BF+7Y+zcI~Fy6g-{2H_&q#%Z{^!WT;0jAYw@)6(2eh?c|PS#o_2dLcFuU0 z6@?*=rn&Q*{_L=dqpoXX~=WC!>Do=61dNJYs(a0k#NNax8T(#w|_O^g5}Ht zwoR@ZoMP<03(8iLUc)q3eI)ISd5spfq}VdXe4W8)4}?GnccE#2?|#_6TtK!$cdw?V zaggbVx6)dB_jr~}5%W{+_wm9syRwYjJ&XDGuBCP&bl8-KhKeHeqpFR#>S)NltkbEl zS%mF8NRz)~tM8QtjBJtzJ9*B5ji{}^XCJ|-8aaOuP^;$xk9tlTk}-h9urb?}R6bVt zQ>`5{Drr6`vYf%)Ug%o1fZuHrZTOPN5QrLo4p)f8rfpHMdvO;yXiFvy+}e;aqeyC? zq%sNv*pvHwPd~xmm)Z2)@G_jo+E50Ip#H6=PMZYv#Tq>WZlvBv+F?N09b6`q1hN+G zKRN0^vlqs|!vsnY@suyT3^f;SfWZsOKZ>K?Gpk;*`_&dGY_9Q7LhJpj4c=wuV7gYx zI?$MZKIbEJh*s-9U=T+lu1Whb+IE!qy^P0g<_1tL^AmHoW0Y=!p4Q?@j=9I~_&6@+ zi#>V!qRQXN?#1Kc!zww@|9z#@&meB7?YU;c2mc`e%#_OH@ z^ZkR!$(69wXV`~4ZOv>5dt;KD^&wp-hShuI_oIEUE82>Gq$)yvR zXeDeTXLE2uyih{cW%2@z|Lm0wG}lpTf1&bA&*=WeFy7v}>amwesVm0kBq*CR4JE)s zr2nf1A@{)%&*XY&z8PwAs5{f7Dn5~ve%~0Z{5|?O6XH_kb+OK{7H<)srvb+?ey&f( zr2w7}#(B#b*uEe~c6dU|xEOY!T(#30WAkVq5X^#cY}va6dWo)coW4z@4>l zdtSkR>$|`h?}cbU4fi}>GRsm-IxE--k}f%bzx!v!tR`d*Qea#}CNh_1(VD<(`qI)o=E5P)Xxxv!0Rr(g`JYi8&VJak4 zK?dea$(ltEoH>P>*k(wK34u>}bM5&`--$k68djE3f1E=gxTpv0#d8#T*d7!v-6i*5 zd`oE^HpWn4o%Xe8(+NelTB4t_9;`-tm{NxI1Tz_dq*y{eI<|u1Wp%uTNeUG+(bNAH z8z;)?&jsh5slN(Yd$}|~`K)#kD%p~af%rqL#d7P8GO5R=bQepCLsA##68&A3FJbA- zjP~NY6VW}(XeZAYwc9iieF$R?kvBVnQRr=oItZ@9v)En%ZD^-U2l)5&?{q^N-CYCw ze7YI7St;Z8dlbUpdbk#Bbp(h4?ABymd;iRvp}Bs}Ig<~*2yAJYEY*@2JTq#ZddfExbaqG;=xUTn!bF34W_;t8{ zUzX035%LjKHmoW*tt_A`vhtictnd&4XjbY&-mR8s3^0|8Qd#un?i)l^=Q>RAOuyBj z0w+rS5O&>S)cic9iwXam$CBkaMq*TbtgA^WMk>g0-xed!?-=l%*m)3Q{tC4tR z#5@zXbzoIZRBN%h$@EYx5aVDPpXmC_N95LVUr?Im7)(ts!Kffk+dna6vZ5JL$U6lI z#%Mt~6X=qV`7Wf@fzqKKUz%xtULju7A`+4u<6tBd6K01YTbbY}{}+C)$ekfZ?7(($f= z;fD43g)ySUFE!T7XG^@0Au<(T&T;U=M|Kcy*#^AF-^2DV0+naK@Zge2%^26;eA zB>+1Nm-NI?1DTfIXhKvkbND;BCv@`>U~M#GaVdUXLz=ZW(9>O#4TWHM z1c~2K@Rx|UC63!N_oICm99Kc1c4lscTt4|SsyjR!UBc|Y-@4+CdntW~myJ_#!_ZTQ zSFst`f>%f@#+r(RLO^y$_&>SkG!iV6Wqm$-`NfC`Xri!L{(IYXmt53b5GyB#3jG#Ks?cSiTy=&tyc zned-s{Iw)RRu78-A?TTf-*KBZ23o_BV?N59NqOKeUD+IS4(P-;H9dJ8{klKCH+$Zd z=GPf_dprBLOx8(_c>=P@Jn+1-@&xz_J(sbb{n8qkjZm=`k1GIac=K#ftxt%XG5#KfpV^W+@gT5e7MfPryu(o z_@EMzQP!WR65|za0cYs2>sKJxSiVwN9wN9u{b7?9--t=eDlo&70nx+U?m6$%d%oOs z+-l?CbF=nBWIYGojV$PkjK zg}U-%JK~)G{=cn2VDy~dQ7rYQaOKcXkDW8?I&)JKE3J)j`WO@`(+OlL736_UrNuEr zR8M-#4<6dqiIH)ul}VnleRWp)0wj`2ZcTOjlt<`hta3DGAQUaHw{FRjO0f1j0G_O~ z7thI)c1fR*Tc#vF-sydUmr_0pW-(6!u(jB6d+GOo1GsqYLQwdg{oO&CAW=qQ09bPQ zd0T)a{b#e4Dt3f1o>7;UVg$=LV{00byBENAFh936_Y;Ar`K4xOGM-N`(+9|`vXKLFPgkPdkk~6_m6WK(-BK64Q8HqN}zUT7h;BU&9H&2l_QhMO?Q-#H`Jw<6eR-bUgVk52~Kks z0b?oFtlxhtX8KR7wqe#eZ6oWM1zG7H!($thui%#9)j1A?GJQ8H|LYvUR3Hb&G)DI_ zQgN^@b4_I0cUXvPp|wPDA3-0*AL9RO0uoGjr~s3Cq-f%HQnntL^;QsIs0 zyPwS?-m4M+$L-r>f})c6|^l;r%r{9gnkAl-HXwS8RN#dv2Gp|woK8dgk`MK?^1lT#qFsYA6Tf%ji~v>mUlQTQRwY${thfdcnQIaH?Ahbst-6o7FH!^n z7ySIJqc>{QS4gsH{%2Z|X>7=)KTe!9TpiPe3 z=b%iu-whF&Abv7HaEU2jMwIWzV*%=mf<8iZ#d9rVa;~$;2Wb7$)#%f-nj)Gw&5o3u zk~eSPKWE0rRbM{Ukp2xfA0bR;$uqX=?p^jqj$a%KOblg=-S>`g*-l1DM}q{4fq zynd_Dul#Dy65I7;_;hSHM&6`1eH+{2o10f5q=CQaDU{rSCqXM`Ay4NwqTRMhA{TC< zI!D}{z;DMSrnv#YC&K_ zV2cXLNcp-a5O}SrWtaDRf_ll^;b_s7p}teyHtt7Jvt`6S#RdNs=IDghh+uEH%h@D4 z+Oq&9)ix#F-_y?&Ktk^lkoipF4rvC=Vibf;HWQPJyDD!b*VkhVYxcp);nz|MSgUR2 z))Cf31E;6=Y7Z>M2}nB2H!ivhwho_E5R3YlI>Y-yYMAGC@Pt0C*`h}_)%6rdKOP?vz;E$U>mTR=@w-PT2n7EgcUq=xv=8q3|B_Xj4E}?> zp7VQVeGT^Bp6vDy^sG=tWak5A__`L{WO%l+^~mG9?{vQG4`54gvk@i48d_J@(EsHv zaS@b)=g8dt{L!~TJ$iL(@a^O(6n7IBd`t58KxRPRO9ji6S#f(ISFI5MIrWwHan071 zrs~SyBypL=_Qv?QCptUmk+&ZsOI*$EZyo*KASXe5LoTo1qZWotF1b$qzxV9{vSLaZ zeQbq>nktvCM3O!uH6eGBG8?UInPOzCEG{+psv#y*eMZ%GA{R<{^AUBO9>Xq~*$juw<;;@H+;=n@fEgm(v}OIg4NnYQ zmjA=KG5=F`5625#?4P;HmLbo^@as`gA+l$Bh4`SI(?f1^kD^ra#3%Q0pk;gG6)r58 z_W#?Tg(EB#GHkTIqHBC|lu-l=$iQuMTOz`~rZIxlgNGHB~-D@o_3~)zd90?!70qnh_VZw+gpc z?~5v?bAzaOzm*~(WACdd<6oIcbAUST)Q?>m&1q%6#FS2rlMVSNVx(5MRhA-VXQ=t*bLkXd6E-TjRfDkRa;G&YQ0Id)gYB zRG?Sp!{Zd#_)HlUulq)sW5oFWt@x;hNnDGhOZy(Blvq8 z8spMFJ_naAzUk;zrqDl=TCd5cTKH<{l6T1l{|eV$Rs{jcCglJse{ zV2(R*>}8>~wT4Bl$M4~c<1_0UYGCBIl!m~uF$OEC`}%%xHnfqhAm6v&7x*5x!|qMe zN~L?RGrqRSjBQCdYI(R&fYh(BYx1<;u}8)maIb)NNs@>#;~yy(P)uSd@za(V1J5lp zbIxD+)8rU8Yz!%s8U&Op0z+f(t;ketwL6bx7FOSrw>V#ipTI_wm(+?4pNz!zKl0Zq zoo6k2ZT-yHj^H;rZu>dcE)+>s#+9&y+u!kVjjfQG!_gl*O0ahU`h#6po{ew4Q{WBK z&S0CL3}=_}P{Q%dGOhwIzvr*NV@E7#_RK1= zw*bp!og{fxh-2zKCV;9szX5tIE4}|kUKF8KPf?cb-_iqHz~WuNMJt3$xqBH&Vk(zL7FqFM6H z$5*V};Z6FxIxwatS8pl(z1H~YUUo)gws>KzOySX}Cy2kHnGDV@J_&}T z3hf7HoxROpPaU!1(hFtb+F;dw-DJ?6P!N(Aw6(Xx=*Dm|k|*{Ex`uKV?~U8Ee@H?p zezf&Ve9djCZ*k+j+hQLCDM&%pF1#@j@0Vh_?}=s@2T7)tj=n3~1Ec=tzH zr)xesin8zNk0!g83e;8YI5wW9Qzhe_-8e4g=qDL5cV_PusG9w&2`is+B^f@H$gJnz zFp*4UYnl4YT3#Lt_$wInw;A&$P_ee~t@ymq4s!g}gIvu=rGu$?p zALDE4iZkQx?fBy^(6^J?ouxutd4rpU}$s1S-|tSYBGkfO~g#ZF|wK_q6)J z&J}ETf2Yy^{y$PDkMxRaPr1_HpY0N}xX}&(9?Q%&Nu!wxtA|^`A`D$&?>!VVT2Go3 z*{jR{xr4!%HT^lGB0gEiAVb;;mFs^%Y}PS;b*4%fRC|L%uK4;kIL5dKqbKY#XN^6& zOs|!%fuZuR!ka^XbkR9}{N8alf4m;t!jv;uh*?wyF%I89Y7S&7@0EWG;+ zZlivXX~ZDouk}2qCnnOoq|XS7spd3*6zSl09McDSHO`)QbKcyN^djoMFYJ_7kuT9Z zi25j*LXFLYg;mb299?d2S&uD%yl@o2{7L>68t3IqkeC~OV&z{IP?Z&p%jG{APdd$Y zOi`Xmi9-?PYou>j2HV-+#5N2K#wkAg`GVn|ss7#^)^h6gU@{gVXg1bf1FP@du3KWdyW#2QsIrZf+BTbnWav7+ zQ4S4wvc~6YQ4j(*_U4=*{wZPm-Jo5dtl8(R=(z>q;9dh}&>xCqWO`p$h3U&oMC0W% zM`ZxN@hgG+#>0w5ZW$;{_SA#|g~*vAT7S#9n}Xj2EiIz>(m77>Z+wbje&nP^4aS-4 zt<8w8h;e`AAa%@*apYzDHZ;Dfy>Xry`=PMZVbTH~g(2)+=c0)aTaLy@n24*9Bx!YQ;}!jJ%cgq$A0VRVI@oKRGoEN+-Z~uxNy?gSAVN_2C7ep`iOlBa zNLTv9cLrJHk6IJZ3B)MxE408#J9DUm}Wh8PP zb1FlG@6n?Kxg@m&f?8_$xN{U|3$hC|d-4(A%4AcV_VD(}uZ1@nc}MV|`8nf9T>;6G zXn9(vC7Adwy{P`}CI77bkl221kdW*FzPW_tc4A0}#QC=!`*|OpQKlgPuhGmn(~ZZ$ ze${4dhiP=~$ukvOy_-$vi(&e-=7U1u@%>&47Yat$l=qp@J?+(I^f=)@lR|Ejeuf|O zJu0aK=g+h3zrPxy?nQdW9x6v2KV=EQt{7TV-!3;W3JTu3O~+t{~&tk&^B zSzabh_Ex)%OQ_I~-)Eje-jqh$Xmnnon|FR5h#Z*QX9`7H^E1iK#vR2%)8ibRb?4!8 ze7VlR2wOQc1e*-i<6Z&}s$X$IsFpVa#Ho?!_VAkWBd-%iQRkK|rYp)Bk4wTYM2Ka& zWvyhmyyv$B(T@R381wlw8ekc8?KN2W@;WK9vsw249QU?@Q!0)bg1&vTo;?~)a=AOe z(od+BS>kSfoskE6v27a|KS;<$VbXyUBB@&ZUrG-F zw~){`>*w#}YZ2htnUxsdUp3Pc+jY!YQKjU@qJNhVdknt%kiswx-v1MH{2yj8`6X>p z0>yoPf7Y%Bh}jfp*&7|WP2A0S#Lo6y3_TD!?MTvx0c7u?UHN~QOd zG}c(-fN>cse%w@`+y_fTM~bY=C2sh(+4?ZjUnY~Wv*vIYv7#AP$tjZCe2&A({vRiS zExqdLRFr=WV%MW9XZ`pS>xejZ6~(d*FYt@ViQsl;+0yM>o%JrdTK+-+;R&64LN5Y=-KoQqf2#H^7g zA!gF$O*$vH<&3mqGN&_X#!r#1+aj2L|7QGmFAVGmNGpBtZqL4dI>U9U-mO$!3zT8} z{y}fT1s^5PAcy~_;nS6gG);UXv%gJT@-PHQ3wNVSj&xacwP|vT>7Oqn*HM{LYs`Mk zWqjpp95YCX?DDc)jxoo^7C%d@p5*Ab+FXgZh+ciBWY*i{`kwlw%wA`lI&s@`PytNi zk1(D2F4e}DD3@>P8$jxH4(XLKPL)X|4hO#{ucEah>1jf%ZI@1+y;)rY%ZaVY;6E8A z7fba{x@RD6>F^i?&!B!G;R#Eysg99i5IB3wr-=YX;Kk_43G!di_HSL){c=I5Q3tnu z>k!B6S0M{%{U=@QH=HXquh|7ujHF7zc??TZ1{Jg!p(J|am8|Wz70)pNW2B?@7;JRp z){-1DdXsoF&ah7NW6QlWdsU7rCQii_{gsVlp#m4<)YlYkia9f8&OYz+1*)_oZ0K0T zgOziThT5#-D@Q+{v5yecak@vlXbd9Nd_051{iLMX$JTZim$Vr`@uaB>!W(%~>k?aF zbTWOxjd`E!Aisy-zOnI+P|PejBJO!jzBUI*E1X@)$G`L#Lt!n)-)nCe8^j7%L!sz$s#$<&M*Xk#uPNp> zX$}y=a0-FMinSE22RHhNkCu^K$Q*dA82`s}ygtwz?vI4?d4RQtpek2Ix#Vqlrnm8~ZBsUY2tPg07sj~9(B(Lt(zF!W#cCWwd(>2#3&b{Dc!21(DI4q@ox#9Z$SbuqVLhi;}b8$ zlV@?&8Cju%&wNjcwi!sgzq=lFE`9Su7738BrV~IP@QPu{aK}#*4D~U5WkmR#MCCdt zO*-JY#y+J?T6w9CV$$wIkaobYK?klS=3%s?b)gKN0n`uTywl#tq< ze8508BPm>TgKN$mpR@04+Ql2?3zs+1c;{G<7!{YF>;HvO>z*FT&sFRc&+Kda8xIBk zqiKp-`>UUZgltd?t?w?Z7k(@=?T9oQ#Lg@I)I)vR_+h4i&6jp9zi&FCUw3CQvA|*ag62&M-&5p&$Y&boh7gyd&-A0AOT*qFUE^HSMEEXcCbN@|9QT@8G^>%=u)3jlP?DJ?k~`JxrwB zzTcR@w{z)F1R^OTMjTgz`!m|g*zO1~jEcM^n34Zvz0lP&f@MZ}*<)YE8Xq%S#?{pjLOmtl1=ncFd$s{pLysy}*?+Jn!wauwfmoSH}} z*ym>0+Tx-7GarA9M%O-Gi z#({N9oxL6NV=o9g=-gdKie#jHvGlBRn^=onWOI57LfzTdQ3~faZ}`nuj__VW^j^rY z1LZpM|0^ird-e*ahv6B78J)UbiLxEDa;;==(-h|l)S!S35--4R3mJHZTyN=O>G2Ke zHUf3$kM<^!^XQqpJjk7cFukgqql|jGi0+yjuSXLFjm)~9?2c*Bbk~22yb&EmUG_-# ztly_fP5vPr`4EycyDGsy65?Yak&Z1+%&BQLPEQ%|TgNqDRLvOD(fTCHUPWzpNM{G+j1Dm|1R@!et;-;CZR>lVe2B_=Z2U1O1CB2L)_0KIVHDAC zQ(k7Y`qb_HVf|s4m_8UyWK6S`**YwZ?NQ!*KUP=!SX7D3^z8F`nd zbWDcG`UF%M!wjwMP1{85nT1JhfsWk-Z@niHP}oP)4Pl*`XmQAl3>zCQeF>uOk_0w_ z^JabW9G_ObQ}EY$xo->GLTvjUzFXib2L$l^PX7%wfB+vksI|T?BZ5t|_7-vVsVfG8 zFQ3ia&7JPT;?r)Z@E~)B9m=MuzLmQoXAS=5Rby^j@Jo3ATAGJ?8ml(T_A+3WEW1dT zahktppM8g76tDjoTVvfJ8@M5b@83xG9DmyLPdbf*dF19W_OpOyRMoXC&EETI^}@`) zv^?rKmuaq$`zIo+wl?kGqkCd-nHBEP?B5=$Wi#=?_3Ogu*?$W*Ohfj>!DhAS1kZ&k z2QGgWPjG>xO^jRFrq@j2?Ha!hC$;E}YEW0oQryAx+;`PL zKKc9dN83|GueKiv;LLXX7Ha8R^ zI4J;;&#!OoeO|3=qqZZmTFSa7e*nb?4aE2CbJ#}4xvre_n@v4paSLmnyOM21u1bpY ziMG|3qWmn)&D5eC5FBhKlapG@>A}#$d?AU-Q6@@3TI=UNkwpeznMx#60=7$g=0OR& z_kYKN(0|Aw?Bi?iab$r60&YMa@+GI(c53h(`>ewrIQe7r?#Q|aI9wDUJC~aFDA0<=JLsRtlKXf$F8l%*(IBCS*pkf`TxZ(qP&(?h7fn{t) zvC?T^*RsTG-(P(BU!!oza6G3Z?me1boQ79q0K~#{r>ulN4LXsh?{z(YM@yZR;_JyY z4wbego{a;;8sD{kc%UT|I^8F%Yd9@T$c1f9kJLqNR0mBb|< zkuBp6{=#{~wq>PQW9lj#|8o-Cvq3>0-m73>v$a6Q5s-5#S!R)^yw&eOF53uN;|5Rb z*?q6U;tz(weXJ9D&1;|2_V0ZJYq@WT2Xr%Ur;F=;Nss|cgDtlk_MCz?gn7knlD;b#>)l~jdF0J{5uiftMaxru$z>shsNtEe21(CM;mgFY6 zV%(qF9Y_7D&bnt_B7|Ap39P&SXhBFAq(zjw%U@u`KDH+`JOD^EgKZ|MWG-SaWo!pk z7=C)*d*bQK-Y1IW5j5lzxqFC^St)`#NKP7cc6|K+b|ysryjmzlWvP zzQ1J3=TqzK$$X)_S21h@!iqU zh3VoM{~UZ&_U@Qe^BGR6T-1II6&RDZJ>E!>tG~nX(6y`z8I9_nuy7yO6VWUZ&g8A! zL;ek%dwhX?#((?TL3@#aHD%153F`6tddE6Z;)JESFAD+cB)RFDxW=s$9@#X-Z>7j@ z!zM4$8%t_i*0N8I>{@>1vqs%Hm=UPv>>|kj?#eaW%_DOLxhDCqTdU~ZXBjAC661PC zc4G53d3Cy2=LBnpG5i(cQMMWH>p4e=GfI*B$h*q_tCE5|Wx~lE^Xvu=%ks||hg$Tv zfu($!&3;ci-Kh$aqlsz5*TYLveFD(OTm;)oH2k}MiSxO~u^tx#RnxYmBY;}Fh_9I+ zU@tf;7|HpJQ~${lWu3e={-AF*T)z!-oZsW-TVIMM{o?(1(6zUg9Kn#SxACG~?qL4q zI@YE)cY!y#uc{75A&b1j@RLpJ7omG+bIIWRFF#S)Ppz?+sApwhUprWlMaFpk&OBf# zJpTM(K-ucCP1xPN%ir4_T!zbfurJQLR*vR-v|=U~WG+`9M7$;RPHT;CGlcdeh<1>4cz<1-8$K9SP{6fTyppOts0 zf(95jcK}B7o!3kxoloo#w6<3OB5uy}=5{i?io-8H<`U1B07|ZQOzAUNG5VO2=VHeSyBRbWUq)_#mN4Z|+lS&FUvd+m0@D8SgQ_MnfYi6qm+{Q2U} z_yQe;i+wqS`!+44`bSa_63Nh*2(lrD*ur9v-7PB{Qawxw&5Hg)pAA}Z=%XiBvp6J zI>Qh;uRSL%b~AqpB~+KE+=g)$c~Npo<$J!k_t*^k_-TH`*si6g?J(op{oW>3?!G55 z37ZRlA&BwX^*#LX?7YIAChKC$QJqH{LIXfo46R3ER`2nu zxvYys<|CXs!}?~}%lp`zBGCYUP)t$QXFOK2mSJF*NBvu>>G!V1mJkgyOI-in^>-15 zI7$TilBb9$;4^ei!5mBhpDm^3y^cYVvxJGtaf}EbNv7fZn=i|_1k<4Vv#jrOPk;j> z7MOna59#Gd3;a;qf6PH0f;cDd>-ns6 zSvWRdi1c`;HQ!2V`b?c*hBvS%E@ zthmBXq?2C%w}77DKBW%Kp6mTqEwZhi6duf927zhuvCE1<4ZkD2jL9r>u8f0zt3kh66EwZ+C!@ zz1(be^~1K1qnF&r#3NI{ClH=P6w4_j9zm4?MBZ<$p^^AL-U4pKcYhNnaomhpKJtV=PqDHe}a&XG4@NS_CbuVM4?I)PsXF!PxNHl(rl;Bs# z`hAb8+L>=kNya#MQ{NVeyOy`+JP`fuqS@7Hnj!b=cXs4wl4V!#*yMCVNS&G*U4c$n zl5$E@ByUo@>Iru;0q+e1_8I`239XqjH<3U`9J`VEKUvG&DKR_fuEEr<(!!pq{qr;1 zHHLg-0f8L1Kk#55zmC#|)?%>l;d$V`S>R-@&98=nu$3qu$LM-vVB#8LJLkbA#L7-R}F~RO=K+#+({D%h;#&twQRND(Y1#+8iU~o?_8CZ{>1m}ql}rP zL?gYAy8X5!Y}9KG@`nilh}M$6C&~$_C-Tfa*)TITZ~o0vI;e_0SWa;;;gB6CsXfeP z>@UBK$08=r7|bZXkek!mE(uyoX5g`ToA`ZY-agKd6!mWdah~0#Q-J6z>*QyS#%-=# zu-!4Zhp8dTH_7tU6%V1)?+}nL(X2Piqk07Fx_yuXYNFq{4Ih~VK{0z~-lf3VjLwGA zDUm4S?g()@q|-kEx8K7b{|~V}yPGpkA*p}yl-Qd{uGck{ysY6k?)wKzl0L4%-}`@Y z3`fpJixedY>~~1Von?=;bo2xuKYj4 z_CopLa{Nz1Na2mA=z`-KT7=gjXF4B`r1-^TSJ_74@tXa_IJxB_oka(C2uH?#KVPSP zzyBX1dkl*Wuhb30nu0Y;ic)5d6J*JZRgDa5_gP7Axi?ytC7mR(|5+Z_=e0I5&b!oK zqR4IgTjSMtCS3UYWj~LBH6pwmxTSp4;>xzj@h<#v7`J`Uy`TyMPD9WrTJ=-`3V6yJ zX{jY%&nMCfFzO^bg9xoL9a%J@bX<3OF@{T5g?UY6_RH!jl+5qpbI){T8{RIF{@g)+ zxdq>E`@$Bym)|QAGV%@y&~%t9X{Y-fjaEP6j|8I6CmHDnEA&aIlJmo$rYfA!wq-UQvK1q^df8L1sor|Po{*i`txxa83J6&5y5{L0G{zatjGw+(K zYv971Oj%_>MQ@v&%;#96`XB<%yd2=e7y0h>_@jm7xF}s!rA7eUr8*ds zPul=g0i7we=1_lR7i_T)^tb&(K8DisvLtoy>y|MkK&?k+9A(g|E=vRXrq^S${MK-s zW)*?^3Ay}5_0jTataTrzC=sgm>Cw)VWXRtE)#?@Q(i+6iWwTho-&L_vIEHQd@kjo) z%S25pYyks_$w}IscV8^A1|(W)At!3#>xHpKxeq3*VCNf ztvvZ8KZ67_n#2)#2Xj>quaizLE^m;S152YA`OG5Tz`3QOMMQ)JFHfAB8Kvg$Zq4Q2 zgqZW&@9p5iHSsKEh_>n&?fgj3^cCQq*7Any{OjLiS3w2Olpi>bhy%{QKQh*uo?85> z5e-mi+XfZ8OT2}cE^Ic`={MgFozF!%_&nXMg_bYbM15|^qnCkKbGrEbJEG*fGRVzY zWPlz1?iF-6ZTz}VDi1Ed7jQ@-^K&}pW zS)8S;75w=<{4-lo6UV{(R~>|S@ZK!2`IvG$H~E9uKyeY)w-l;nN;9U3L~!DbX(TGz zO@wcX1XF{4zDop*b>f;I`kHIY%Di)0NjHBsgyIv!1=?$8Gbm+4QrY`g6&w?E^c22? zi1U8d>O?SPnFBb&m5ZM2;CegT*2K~LM96f;>ozJqG|4gl&#;rq2E@fK=!kci@R$b( zRLdXH^Bfmw$m2%CjiiO9EpR#q&m&=Xy1l;{ceXUDjH6JOm0O!z^9*~tvqT+jm;QoT z>oH+~#ACRG-0ilRTd5=YbFT53ih_oHI;z&e-_+%W0Yt962JuD3j7`kT-=xg%$V@;-NtR{74E z4qI);zmtj0SEH=s{d?yV+11{W zbcQ4__ngCA6Lfr&AY#{g7yPuZ3Mpxu&}RZyP+_r8c}U8aLI{3Eq8R1m7<{d{YKui|#odD|h{5dnGTjk%S_jl0Tb1yB|1S37m@fEK3oIdE)<5x|?V<0X4b+#Eh zUGno{5+||-MYeT*Z9@dhs05nMO)D_^{jB;KnG9h?t9en`Id1Z0FeTP<$`ne#8cnH4 z<#71#;fLZiQBkxt;<0-$D)K99*?UrsaGevqWvSTBTQM7j?hfzkl%X$lNuC4W?u7{R zT}a)R@*Y6vhMHVB-Z4xWt}?H7LK($DarM~qYzHVqr8O{*|9kX0QAK+7U99C+&5G#( z&OauH*RMLp)#`#T0hGL|dSEhkkKD&JtjHZRz>tqF*P$<1F>_@RgTA!?DIJ%}WTsaN zF#}F4#W!JXmnyIX&r<8Xe0DXzv!oR-bk~uKVE=xdw!>kiuNjr5PIzxJ{tVY+eWQrT zTRhHm9Zrki=lrfrcm8QcZ;l^CKY70X#3|pxxVEA}betds$tL(zrk$=vKl0^!lmL-P zrYyPT`g``Vy*^9musyDKOn)qft;Ebdp~NGe`qZs%*vK8V@d%ZDzT6WY|I&z*&F&l6d!$DDsL% zU;*sSuYHEHy|&0ZIsvu5Buq@mduD4gd!(=CSR6Da`BQ+>v&>Y;g>Xu-r*Rr;oaM~_ zmExc>nP)?mv*w+pDC{Ltq!8xN*IDC79mnzW58@cF6N7Ad4$TwCqCuwtSj+%vG#t5E ze5oU&Q_9PZmog?}V`V;eWNU0jw(8`Qk;P_=b5|Q#%>3Ns6`cWS+%lArMzJ)|jDwbu z5idvMX2ybBA!bOv`xjWg=f#r^BF+@wg=K6=6l3adL!X(d^a!GakIyeP@7;=pwNm;` zxO4PdFb!?lXgl80c(z73rnu&CdlKnRthYG7&;e<jh|2FKH1i2r6{V(3bxG|Lg zZYE^-`WeyOww>sSwO{9`mv^|X6L5j0fKP?|arW(=qLj#W%EI;;qbKBTbb)c4K&FWc z$*3ltYF>43-PDsK>~6^uj=%WK{GY$4kNGOk>S{Yb6A6e)n}X|%#^}^% zoHMO&cEwt(bqYXW5C$alZ;S+t4phGmjI$h$y9rh#;l^8*{Fauv$nEt{e9&|*>q&%| zF)D9K(0Z`V6Ja?aNO-);Zlmu}CSi@M)Y#sg1JhIs9lRM*lR~M@#>?Cr<{w|Lv!Ko6 zn4)Yg=<1?Gimp!pc?Ib1d5WEEVo-gu2YLQVvb=h*O;!=#dsMz5F%fxkw|Ixvs|a=B z6`|WXvS{CnPPsm~5h=Z-5qhbK`82+}i>tg_Ge_I^DhzDG{3IMI6qj+zbV0dX?{s-U zZ}*cF9KZ!s`R{fO)WY~Oh4l9B`(qn81$isJBN`|2A{Sss-^qNaRk)L1B?<+6fh*J8 z`_18voAJ?;7x3LUnNjT-P3eSX_nETLZ?c>U-#u?QFeL+pHOYsu;SFTvwfCp0-a7yR z7^t%jfM-f!^rdL5SoAHKyA+c(zG|fbq==rIRnvI5|5`{EA5-v8zT@8f4uqhi@R@9G z*`+)sz$!Qqwz8I1S`?9ZbQMvVU_ErXhFalBsMfxR-@keiO;+zVh}=PTj!0@)eohSd zU3wV|cWwI7&J_e<1E=QzaWo9aLpkT)VJT+#$*3f}pqN%d&i6ZQCqFMW^cGo||0a*v zvvk+r@(S4_XqAnx7`8r=jk zDkZ_XeInpt?@^RLL+{o=p%}>=SSvcwnSOdL)2MsroK z0L>2t?2IGz<`z%F?igh0g!)Wrxp_}2YlkEvFMTP<_75_cNo4mSlBFdzUC&vz@FN9v zl)To{7U2n%!J`|oqu8H~SUN?{98K;ph>%`bp9?|9wU3R|)!2PYZrLG;7QL3z`D90` z$-A9;SJpmINr#oI0?@eZe=8=ujRi(lKx1!p{op@Ll1`LKIf<@RA!OXOET{g*<41)sgw z=q{Dplz$fSK5r>#9CClt~Ktd6wtCOf87UdfLpZ4*TT#B;!(UAAI^oUfG-OlV$%n%R6B6B~`54+mG^RU76V-KI^jjJX+73z_=_T$ah{BR5 z=C8i^cIxx#R+M9<1zy3p0hn&L*iDrLM|g~{t;OmLRp~%lBxXc8)>y;-f3JR28b>h@ zisT$V`9^3CQVES*CntbCqM6U>ep+3jKqk9P*mr6v@0&A6692R(eUEx>m#R zrXNogi_5~q^n3W2VDDALCkE91&Jkg66dZL;j09z#b5x?i08!>-v>s`MJo;7U*5;}e z@td@kNMnoRz63wZ^8Fov4yAK;tDy~34QW)5Q<6FE*0_Ul%<};qqU1TNZGQ~=szkI# z4D>5Rc8XV4TvyTaAKBmW@LCZ>J^NgTcc#PEVqiTgFs5|ww?puui|vhao9LWz1=k%Q znY5p`RG?eyqdo&A%1$Gkp9~V%DnnT`isf%GKN^c)(S!I-?>)uLkvADz7`7Vglww}q zQDprHt`OVh=_xuo;O_)jOek`e?aHKw{OyxRh}H*>8B(Q8TCUMA680DzSPg+RP&y?r zL-M^mJ1;;Vt4CP>DzR=v!`xNA^;3O20`vfT*pl=?yn8b*e0qI?T$A9)i{XVM zEcF1L%%Zg*JJkPAU7 zEn4D_?tnr&HOS~*sXdR{q&EiTG3A=8aY9b7q<)$kQAA-QqtW8mqM~7ihyIR zygk1`(31Ji78&nbGVe0)T@P-_JDEn^t1erzV}17zxg;gq$(oKP7qL(tn_6s0B`|e6 z_;8n${(SxTt%~A1Fk^;uiy=`W)A14ZX&M}C#d8)ahPCQ<#}yuDnF5rygd2Y)%{!L1 zT>RzcDI%hXV-&a^*JnZiF>j$A#f4?V6h7n2>CSo0r>NXZ9(Y*qw%vH+iDU zLD%{HMoIgf@CZ^MqrFVWzh#qY-ywgCJ(-4Wj5TzROHRMD>wic5ZsZLPLIA_IvtJJ5z7mf&ELfRt<9h z+N$?`;z`-OToo%HOWDNekXi|L{O2ChHGRorvOgqMG+JE0e0pjP%auNYRl}5181Y`3 zfTDG#aS+3&Oq2L}227?xgK2$_UZn+CYj9gO$e8(>mF$L|+t;(Q0osN@evPS^ST>S@ z3#+duyRuj{#!Oe!Nr}9X8q46kr$>>eaDr}xT%Jiunws2cUXR>unHmzV-jP&~xKh_- zKmKV-yYzr)&!|#hpBBY#(!T2;f%CJ|s=f%6m)`4%kPgcJJ?{P)E@K`w1sZb1j&^@V zH|Q4Qa3*x^yvoFOB$v*Rb^H|zh}N+*&NcM5qec$TGt7nG+WsE=nWJVa#c0i2|1%Xq zb${Bg!#4yCAdR6*U*oTI_w#0!7c9w3RKr=O-f;hL0QGpA@pM2H$HCZeE}-dU_GO=K zEj1RqcdNePKZ-~^F4l+3y0f*<#8f9|6sn&YgzW^=jaF0t+c;B9}A@& z4;XBTMo$@%@&ac&_Rd~prob!$INdEKXiL$|8hI>Ge=k_EvoUnL3c4lpy}~7SIm>&> zUWGv{h~sNre}6438=9zn^kX%@Edv5Qa&I^l=+2@vY5Mao%R_r2fB#pae&EkgPx*UmeKn1m4DabtM+e*a0FQuE1_t` zCH?euw4T0o?9X7DgdBWu>GKD2G%Ltws8Nnyp|y$rnsE;24ZU9Ek}^P zq94#u4mnf*lM;ax!QT?jvChD<*b;@6^5*^?ezbEdLR=#Iuj_U{;$Md=?cWT6g)AP; za(}&_2syBYlDU%7)DnLam_gl`(45%b4O0}W{84I*4IGaPu?{Rr86{@RE zQCTLcW-@Y_6f$P`cqvDRS5R}|9I*6z?jK<}GEA27*RXR;#3_cp#R}=e<41_QB5U0} z)`H&p#?8Ud&zAWqRKiDC2`pEm?BV7l=sz7eP43Fh{8f9eYst7I!h|`YR)yWOdh~nx zkQiqumcm{s+d6czymxH3Yc7Mlm4N2Y+MF~43(ZM1wP~VQ=e{-nJUBJ4FGwH*spXvx z5SVNX@^2VK$*WxLGc21Lr(Gu49g9kcPF#KM9gWPQ!SK*5 z?BB!RbN`T$W7+O=rz2y!(e}GY?0y?;PKt=7iEcbOCu;%hQ@OA4yU!r`nOHWsJ7b8N zHTuj}kc9jjHH6RfpD%L;|E?5t;FR0C8dSw|COA*5w}l16H}^X-H3DWM)zJKXb6~7E z3=(%^J#JHHOWIXCTup~m379bhM)rg$k2EIfAX3_J>RWWZp^qmY~YPO;?0bxw%pX* zpNg{{?8x@Ydb_8X2ihtW5Zpfp!)RyEC{YKm33+v;%JZoeX|{T16Zww)#1f4KdQzs* zJp4KhEXT7m521I0z!J$4*AuVH=8qV@#>8z46o1XmFqK; zx@HW`v+%2JGL=wmlrwf%2)R-I_JE%^&7K^leUH20DY5>^ZQz4!j`~hY1bKV; za-@!pi0O#s)^YvarG z9^T%%fit?gSg^a@T2@LC1n$OUJXjEBkrK{gS>R+vZM;z<=f}A^dz&Nuik)=krx~QE z;$5~atSpr_8+8KVzx~?vsz$#9>aV%>$=gjzRcm8bVjwBA`gAvs{Ww-IXB^__0r4ou zT%;uZYfq+`QR)nO8&*cy>W(a4H3R}6o48F#@Tn7?K_JbNXMDEgNo)SnW zAOOX)DQn(o-BDXV7tb$)-z?G(b4GrTBoC@#+PAC0>+d1IC;wzPMqGGwcR&Q>mx#sb za>jFy(e(Qzl#gnu%3(|gLGXE6HbY~Y@aE4f9<}A*sC_0Wa5}W#peCbWNq~%I zhbZx8Rt{j@|gYV`Gs1R%nQB_j~vnSki1$ zA>@a1oI2Q>DMA>9_WfSNN-e~gk+(x0$We30(!t7CP`i*@C%ioa(_i4=HS^i#xek{m zO)49L*R>1RHOu<=2)Pg{VnYC-j!urZG~(-T9yQ8SJ}>83nct%dIgThkWsbbTzg&H# zDVIBO&K#);zW9ese#`;S8wPQg{WP&OcY@cEWRI2Hp6pKw#&}PP;}(W?y0w?F&auwV zy`f7((!awo(-5_pss#&W*=Mc-h~&8N1c*9>jw8}DQr?%`V?&aZWl`o)gsf#=FCKLq zhu&@!*xOvHS92I?S@?F5=m;Y@c#Z#k=s_o4^}WNxm3Gx{Tj3SJ_mol=v?ml4;R_ATE69PtLq8j~LW zoMzE%!9BHV!7%d0hZ&TYX!KirtRiU$AYF}mZ@A)ddrRH?AsX#S! z(Id8Pv^+UIM+VHFE)!hj*Nj|=o2@at%T*c^LQ9^?-UqQ>cK z+P)H%UT^t4XCBU}|IH8J3I@dty)rm-`hjWKErQ5`8J2wl8J9_aU*euvAxG=rZ? z3@rPOLd9vk@{oB8$u!hkW-b4k-SZtu?p6qn5`7Y!;6x41emi5l?Jowse9h8}PNbKY zbQUK*RU|SL%XD*{MonY)<}_(9ja;q+9W4Gk!oEk3`GAOHJ@Fu$;Rd52NmfQkIQLtWBf5uzDwZ@^y;!zG&81n3cpbILrL;%{^| z%?zh%P4E?H*KN8taJlNhyG<7#UKIX=KQWTSLK9T050jMgt11zJkkN!T?)v50> zjvhrxsZ6tEVzElZplD!w6=~$oS8^ZaKWM?jn|6H#I)T)wR@sA6!V@1nUXDfCi*iwy&2A39d zt|cr8h-@L(y-u|QSm7B{iW|t8(I3TD)QKgQ8?Bv&sKo0h0}_8*-D8_x6NZZtx}AS( zeFAltOvAND*BsjGxNf4qs`Lk)c}mYx5|4&)vKgOz|)SKW%O>w$jq0pQeo(aO#Dr&~*SnXCsK(L3y(Jqw^NWnGiya4rHk|S3sV1b%|L_SZJ31T zAK6Do+ejku%&JQ!*4Q14ALBjHk#hFwtY)%WD=^k5oXWx1dDOL)Yanbw*gdeGE4Om&GJwAjK9W95!dnB>J z)!p=z1B`kHIwQlZWDAqm=3Ub3o>`=?pT&(3+ZTEV7Mo;L!;d#V@AH9lby)eEwla%{ zj>~F7^iUlj_{g4DQ~Wo#IJ!Hxz$`~8MX~3rQM2OR&!nb33DUW10PE}ftP|X+8U~x!g$w)5w(|l4^k+c?04}?m-N^`B{bsYmpb4OemC6* zKsiz|5}(oXW3XY6$b`{RqEV8K2bZ+xTw(8&nUgPVH=!ph`SQ*jeKkRPKS95F_fx9o zF+~jmtvRzQM!noxilTLyQ1;D3g3QP^wl~M#wzOd!h8#*z1OL68&m4*kPzw^>L(8{wVpzef8Z96K6!+tC|PfzWY#&|GEjg4kvMD|QOcz;L&yVWcBjCXVVZ>O$i zFR|$(cfrA_tJbz&-qSm|ln$NkV0FqBA=?~0eGw$XU3{1Lg0BSV1p_2yK5bMqacW0t z>@EmhR%@``@ZW&(J*JB;2hL`BnY=snRYQ=5W?%H^mYrTfzKlJ>K!_Q5-zM*~;%;f{ zrWTl;wco-azhzdS$Fl;9Ub+5NUc*?jLgB(c?*9S#|QfX#&M}Mwwa9q4c4t7 z|Ke7F`dywXpm`hkuRH#YiD!%C&9DHu83FUifh+Xi*S46?@yV_^t8Z?wT=7pa>S+;D zj(?xNS(TYFhzaUokEvaw)?R?s%-QxfshH7Aa$F9HQ8anH1}TjyYVghAIFdbL+0Lot zm;LqcV#SwzLS6iI3}yMQjCS6?B3H$*5U>!6{_T0s33Iq%x47eUYBOD=CW5isCR2i3 zj^~2-`cM4bO856Qexx1avmwcAoVVF|+05Hk9Fr+W_5#^(mHSa|?MWmZB(+WCn1J1~ ziEo#MzxGxrIXb|9@8&rqt}=v5l}ILP23IJP_3}GM^&}hTI~iT~u1UfJbX4{T>>2(c zMUK&j>md4*Q6(&0o;TBRlW3aub}hhdM!ke_Il~wfqGk-sNm7yT6a#aUdbj zHb-652tmkbqytDkw(wpkg;Bl(wE4@y&jq?*oL5y@&{=(Q;OWID~D&}gr&I1-Hl ztgeX?$A*X~u{RakyX}QevV`EmwvjW9A>=Kb=C60|FTIxKoyhLmC-qOVG()uT+Vo$< zz8)HjxY29sF|TNrAbClQWD~@`cB70gO-Fa@QxjdcBCs7glH{CU@L{HK5DQIwUp(E3 z8_x(!fg4L=F<5Y-exH4G!^$hk41cDo;rg_QdjE{_vCFKa)(<=!{*spcF5JXCmkfT_ z=)dR36;&2R)~CZMq=)D1cQ@T7A8+4hr#FyEXaL-rR5J)S48m|EU$d<&!FH2W_kH}q zK5V0*eL3WtgV^f7$NNv4(-k*gFDYLGLrK!Up__oGoFx!SOo%ad1d`VGJAZ0>8) zW-`rD26#q!mCGHQ4Gf{jnnQJd!OQGu21*K`89{D=gYGn29{EyTpCFIFH_AEA2C*j+ z#%cN6VobjRYXg(FrNTUC)dq!o&-h7U%&A^I!Os*0P z9II~SFp-+v1L=QRW=yfBgI|D?@4rCd;~K^)NwVn4l1@J{pfNBgfd}4)ygVA~QAV+`CkZl<3L5}tv z^vS=zjXJlI)BXJfpCa9CbJZJ?Ft`7iAgoE!QO@7sXC|e0l;HEf7qv^LQM>X&={ZYK zGhx{czw0ap$T|e443N_$Fv!_xdcN1_Kpd!-6?C(_?QiNOPG-xVnC1?_L~~%cIowwd zJSnOMs~zt?&>9uu)ii}r#%zu_kMTCh*5Q0DuM0^#Ul!Q3o|@FDykLuk|O2Ki)~!_dx5tgJI%*K z=vpU3!+(g#qp!=E(&NCXI!0^IAee_eb(x>RM&xUHLxc3lJk2v!%D!pfoN*O^WVj7* zlbT=Sqixp6(R0a@7}ct3nD2b-jG@D%ZQTK zQa{bf&BEFXnfbBl?9XI11=Ph5y^99>SF&&y64_~ImFCcNik$8DqgK2a2i$9dxjY5u z|1zM#O*q#6RQUHXS`v=bkEt`6d~f7oRIqIs-UH+08h!bhkO*(Y@15dxTL)apAdYr} zPj!vulmg8=@(ItMw6a(m*t#6Yx16lW+iKcmq)6;v5yn}Dr^XF8p0%BzEEh!oCpc9x}1zN45s%IkR}o{+iC zq#J1*nSib$eH(%~XZgQxKBl!uHVtB56ph=T3_nDzCjZJP!74GL*UCN#nb&Z3>7sYF z;#hgs@~})H zo4{bc4cXs6@zGF}h+i!6n7hV5j)t!OeoJ~Ncf?zY82t>9-LeH%>m8g@&oSL(F;B<- z4ED`%=$g*;88pEibYy~kCL8GiJTiIAqq_dDb_Wx=<_xCfwWZG+;zggBynwseOrWK;s z%-M~}6y-XyJYr;tsz>-Wyo+oG;m$qN|l?uB) zv;HymL9v`)$fwd*fZ089S3Ji}#?P5leQM=LT9+8uZTn`Xp1rj=t#OazCwq_pM%PA4 zWc{Yb`7XXo>2aT~5{pI1fK`kHT<}vmdPR87fu$_lSdnMCs@c<1pV&~2V~}NqJehjx zod(<20r!nF^DnLP)F`X;lTlXD_|Y@f7m4JAV6{Co3WAR`tcHkUV+?}*Ju+ceNF4<| zzMr?X9#!oIn%8d9baB)RW7ISP+kz=eH>wGaHoUYk8pvE6d36<+-aVQy|2Rk%QE_DB zwB5ud=8SNbo53IP0n(SjJ2}RQVF>(%ZS#HbyfWTDGEhc?cFMozqlL1u_cp;NSHN4Ii$8=}*{Z!Q#1m5Lnm2-M%8J~e?8{hKa9|9v zK<*nGD|9GbjjR30((npqdK(WG5(rz}?`^Zy;jeP{6j3)qCT;6kTBR_H-~Ige#ScSH zy>&Gu9lrKjDIh9oi0jmNcz=`$zk}w$h$<{zZ>=WoHF7@kZKG}Cz>fnCO-AG%_yG9 z)QHA{b{|ZI<*(xs&U&9ADzyT228UxaMrv0dv`~g-d8NW?maqQ%4g8?5%b#-g3YI!u zsdxbx2ekJCwDFx@|BW>|+w?_3#I{I96U87d+h7Q2aE6s=@`1yq3s)7QJm}J1Z$PP| zkhii5&N1wBto$lS%8Mqb7TyzBw|r3Tq8_>dFFaY}fwx86k~Abdx}d+HZWYDf2Oq@4 z7F8sC%UnypUvek15$tl#_zgl68m&a)@8DkBrNxM9IJ(786z^gj`1?YQ#cU#VgoOCM zb-NtE`~(0|P>tpa+;Hdl)}JJuulbmQ+K11I-l_I`r~bb7Nzi^G<^6hg_r`oOV~^vG z16wUaH&a4Lp8V!-xz3A}YyGhZ8cO7)o68OG`>x*fYex})F;E=~=Bs3xLvRJkR)$jR za-r^;7=$2fmQhHp~uk@+w60@&+h}Gu5zsTNNQWbiB zy;A%n_Gh8Q3qJo=ReM855TM?8 zVhw3QnF+xtb@BM+F?Rl22s;aQ$UQKHju)9jS{pHO4?No$hj*vxDa5;U>enSU6txhE4_nT4{ zR4xO188v|DwTt%IiQU;p=jy-181OY!8-JeB|Murn-R?>2aP%r5yNf+Ic)u~jgv+ky zB#8c5G-evjNK_$26VxE@3f$b3Vjb6VseL9rQ88QQEHf;WY(LWUb%_aGBi9fT*O|Ta z%%C;X(;FG)Ot?I*Y!V`+(XXal%Bo4=r{as$KN;ODQ}yB0Am14|{G>x2co@x@8_jF@ z7+Yxr#|{5R)F!&Uz6>25&UM&@dK&-5W)+CCpUsM@+-pLKc*tMtJ2q3bn7TxO4O#E< zV2Iy|aelpCw9>0g@xll7dcb}+9~t-l_t|Hux};OXxnCv?OA4Nw?ma(v1(1>(h4x}X znD5@X^PNOUcWQfb7!hOU&`*k5JpbRP-=}CGMT}E(3@V34#TSPwvZdgaYQ?ksLPWKS zS!ib2%ncU)&h~ebRXb`ph1Suvz^T5DBa@$j=hb~hd{7u)x^Gh*x|!>?Y~$?l<&lxvV)b8jj4#)@11K7Fm+Th8oh zSi1eiu&cQuLJk98GGEiuB&>spVYCP2fHzQ@d$6LU=1Fr%S_WD866#dFzk&3B-weG% z2RvgxJop-6g1Uk0#}z;wF*_tvxy!%T?)UGLHyb6%CB{C?k)E&b02|_Yep|mFm#bV+ zfd;0r7?~t4TKS030Tnlqy;@qNHJHLTHT#7z%zu}scMsfdqhHG5M^$9op zMktq+>kSy_$UWU0@DJ7Zb4*hx5X940DDl6fCgH8`W)uwsw6gH>+#yS2q0+D|8YQBB z^NruH>uENfWCS?hvQ=0!$@+|DoZ=(zc(yd$iCC)e*EgJ)#r$j)a6tB%(eg`9(2%|B zHVHo?fhhFk%^GOh++^+eA_1MTN2-`BedS)dqE_1mNsU*S=10go^L^1%J`TM$8j}d` zct^E6lTH^)ojqSSI7hQ#1X+4|yoXyHtoNzTASzIYf$Xk7O(ORbSb`Sk}cU0&R zbB}t1f*#VyNq-+1XG2oR#ZhE_AHCTw*fCD(u{!hjI(roIH&ArRXXQi4&zBE z<~Y5_pkyR!?P8cXh!}smw}0-L{oku*R1)h*lb(-@~8K$-nm5eLJ$Jj0X3Dp5Hjjzrs)NR~xOXcpF1(BTweN z-=f4&L``?eEEF|$T|SOrkHwIm$6g%?oZv9TI8zE?;tMa{hs$&j!A{lcJSK)mV2wxy5im;{ znIZdYCS%8j$iZWB*vWC<`uAKB?P4^Hk;{^jx*Lwavz75^$-|WQhhugv0_u3;k2v$u zLso$xchM8~_u-TLI%e#|5VD`~I38tLiwzU7oU`K!m&X}500M!F!1C5N%cL&Zwyb|~ ziQ|TSOqX>ic!>W-(=3-FvUVr3;G&0*bWJ9s1G&a+MH^>g=W2bZo26nA*Z&=p_bA*x zemCV~J`U6!WS(FfLnn&qnZIpKy<;B|bbP0f+`m%)3EUNtBgx@XQFNoyO~;U$&TVXe zugXyZsuq?6uck2yc(?uurZm{2yT}kD`sG7yoy)joCZT3iMQ236GBu(SJhNHQ&>Fju zU-{QPZDP0$@h`X8oVd5u>UcI2?*#GDl$0C4BhA!Xmjcq%7eF!b`v>SigMef`?I{41?OE^{;3>qA1|LjjmhcsPC~0*SYQgi~aLn6N0Bc)P8^q?XG$ImG5#2 znnV$}6bWl0?(cX->c8STc$i_0Y3x>uM*N5Q(>u53heGF-g8wPkFcEW-61IFZyQVL>`1HO{8zw3H63C0YvPG}S<1uz= zS5rGQ?*S15Zoo5Vhq|8Bim+yuuIbCNW^}i?<5@ltTGvKAE~Oa(g44~NR}}q4nxz8_51Yad`CE#kL`#i(tV>aE&4Okhu(srzA-5 z)6(OBI9ooE`x=*2<3~8yW1K*LNxC=szLOR6e z%u#!abu4Easff#L^b>pNmEVCx2{T$OXf=@yebl-0Er! zXjs+EGf-xhzrJZegEmBrzf{;0QgeF?=m9=v3hg$(jS=trp%s7+(nvuL&z$~Gw$bM;rSQByoGD?3PU0PnSX6O=P&Vk- za8Xq#HlcDA0#sSe^W+31$_Doi8lWh)?W0$ssrXiG$4qG@*2%1^t$Cf#5ps2kBrE-rL54I(U)KN`okf3M7Q1-GU~)2 z0w0Oqk1tYv&)M{S_-RFVi(n=S4c77>2R7oYN3V0m zn%D5(Zn-x=C=f~8qxkJuvHV9(0Gk|+gH9zl!e@lr!&I1fK&%Wvj&a5#vzf{D_tM~s z5plt_k>sgbpmg^8o|psTbPpAQ;NKei>^nS}b5lf!#-6tMO`K1`_coH+)2^wtS=8hBo3!t z+Yj6x+XKSnzx1nfo825N*3_!wA5WwthVRC%fy5f!)D<`UHh59x+ zC5Qd^=5}$8*H8gNw(>bnbd7aZD~59v($anJXW~3vM7~b%Ugm~@`PbX-e;&hgn~f^V zqG`_i1tkQJjJUTxQ#0%^VEIDV^&1tDEtL&qP~95}nDC^}Rm=Y?P+JK~&oZXDlShSE z#dFX&DVD4(+494D&z;%?mW-Vhe)+KRt*zq!?xQyUc__^a!Rq%BQ&Sr6wf^@zzxh@$ zw9VNuj-lTN@PyzdZNtQV%U1FmHcRonCw?Lu*yau0Cb527r$p#p^LJux`F+_<@j}IN zkUY68v?!j78qA1Vf8Tqj-d;c%+v5Ih131A+R*x)Vos7=Y@FfC6degKWZa71cO7GiU z4ElaPqfB(`yonpl>@)PWa*0>|D`o~2Qg@(sHdu0*o+4IZhCpKf4#qgcuEW14K7pt9 zzl=eWiYWuUt(Iiu5;Kyyw@i)ZlC??5QKdd4S|C9xeY{3#{xWw0BPeLSZk58>PM2Y3 z8+`<-IUt<4m(z7*L`wRTl#6xs1kG2{;H;?)=acWtH1ydC?;&+GPiI{v=TpLu&YimA z&?NE-{7d?vZI9eBIDE3Kzz6#$1FzjZa?CyVfGMssaill?oFag~3TgrTF8A1?yx^Q) z$Fq>0CORz@=~wpd4Sj9d7!>a!<{W>5b*gipPgYjM0_KMEYzQG;(_)~~Dk}ch1}Gda zQ#Hbr(#1dxe}woo$!F$@Nf{(AyU=_o^0z^m{r(CD*gHYNGu=4ho~o51<|}#kZxZ1d zH{+y)oaaS=+V6kGoxEn9aAmYv_57I&M6qPrTmMY^5pgUn$Ps5}M*@nrO9|ZlWq-Lf zSbSQ4p0W+7V!c)8mUfm>GNgKA`7)T|M^f3Wf5wP&AATAGLcWLVKL?PnhOWm4PTCQ! z>;a7%6vo=f|8~8P07>V6q>XU)&3+Ir-*z~Ksy^G#_e}om}hR1Tf+D+zgGQDl$ucbKSFeSM-9vxiqZF?@|_cGWix^pgwrtWSQ{G z*%^ZIy)Av9!#p;>zpFu3U%YII%b3Kb#?NFccwh2pnmNmoD>R7Z_N|wWf;Wf?t9i`F7gKn2a)jjye4?*zVH zSB$JEa(C$xURQ^%8ML|N`uE6>QBYX?vlSjUq1z##^B{Um{*5sJ$lriz?{t&5D{$%8 z106GHF(N!CUf>?fMrfBN=QJ_xREuIaH38-9XVR$wubUB=1yJdtrrO^==}w+E1Mu&@ zwo?B-`lk*M%&@{8y+Z_pMf=NmO0%nT62ng2`V2rs-ivmim2$rv&jsfEy^JTvyk8pA zrp4-K96YjL8RgfJ>TZFiO=T=*StSjhoW*upt(VAdm;7v1*xyI5Ht*u+&T}?TY@t6w zsFV@VCgo#_Hl&$CllLNawLMNuaAeA7KH5^-EQ!m@T6pXHfo(I0ur~Ul@P3Hf64Xr1 zN*BYD5XX7MgrDYt<^0Z)_?_;@(y3L3+I3{GZ2@n1muQ!5{olkr#&5w2^75!uS2En2Bs(fLDeFm1kY%SH z_pfI1BcDaGfxbQ z1iW{D`#=Pk@=K(cIUR|%Ga>Zbqz*GJTF!9w_pB^Uzx8EG2IEs>Ue;2M-fNQFwq*V! zrr{aCkN+JXe)C%t%6g6xGK0G!{P(Zz0w6}Tt~N;dAZrZlYNq4E+j44IZ~%68b-l(H zpe1gfhU#Q97D>RgGeU+1@aKacx-+Pj>S=nc)3__dl>a1`kK+GtD|U=&!$7;u3Wnyu zbzse((8+BPodSwKySV3`D9m%z_1eOq$a!mUu1SQ)L2iUs9mr+$2X|E7T=ietE?x6XzgV5e+HgN)7uACk3@Nnak0$;jBN3r2h zs;xQ(tLT-~vQ6Q&P7CUoRTrN#*TzzjbF`N4Jfe<0l`I+V`hjn(rQC+mU2L$y!W1k^ zJ?{6}>)6r34sLwBxi6aF3}>m+nD`l&q;l(A#u@=$9&O{Y)Si~(3T{cT@qY7AbB>%B z!}3gTW`wYcx1CUh+M3tt9Dkdr{VK>CFe(ym@QSUd1rU<&laK1xQxX4s8M7LhEI89c zE9swc;>uQ`s4fP= z084oaF_2h^@M{UK^iGEI_tCe}I`Clf#<^E}UN}uOY40<+E=49J^jfOEwv3K|aR;(r zg5?FCwHfX*ZU<=`XC@D`kQDq2uO&CU{mSuOTz4=tA{}ftLTG<&Ck)J=ubonQv9Qqj zeb4$itk{7!{(h%l**`;#xBvh`_o%=TB-!lzFQB8gm197;4YsUfLox&T#zl8m`YLBEB6Sbp)n{ru?_p$`5DIk`{v!q9_IBU67~t`_hEadSK2c8 zxl5=E&B4Ziu}2k{!L@WH=X_8cMES3!=r5s5@X%g(mZ zMPCjUmG5Z436`xx3@%N+d!Mz;G>Kl0iSz~$)7rj?=sLN#jx|Nz+HZF&`u$=?)wN}G zdVWz7#xvaJB9u2T^>L1mJjG44C4|JkaeJCk-aJ$sBHO)-$r{jvlOS$RcbeASy~_(b z%Xf-NK0vK)GP`Xe@;FcLzi_LkY-7wlOe=>Za`=uUw~Zym><@x$ExIFU(leA0K8pAVFM{mae1CvgnYhgzW9D}g@SAjMzb=~7NN_t-t_ zUz%_X8_!>kW`3+Ar3lY<^`kF@WW$)GXjGmnIf-uXC$iZYKmJHo_1~6bC`#7c&tN+E zUMhWG1pr^|HFp!(tVh(I`kp8t!{C+CW#0xFV_JKaJ}9a28~vKmoOF>5HK@j{(P(Qw zg@s%jhmT6#V+e?@R`f=HPo%6Q?I2SHH|x)w9jh~7!77Kh5Zxx5?E8=X>PyI zP|*862C}u#S4E#IKNl7)zCW&8=RLn$)8lKu?9-;aJ9<6YHi<=?N0z=bL6Cs{ z_s_1u*W#^{04B{*DEuvjCjl?embod_M-R?rFzKCuJ)D>kX<<}%@23hdq+m}HDf_%L z-rYT}?~H`&q6^8WaagZmpVAR5VMVTXd+$Ihz4a!G2j!g-|7caZq_;*cSZjh?E_@$- zQmZI&X zTS8#bJa03r?BRGm-_6@G8PRSp?(E#gfl3zPWT}nyLwg*xKD&#qCrcAeb!QY7*%!uf zz2br{&Gda`zX$uu9q~6?y2kmtm-Qiy?(D+buJT>fNBZ3n38=iDUf_Kc4yFrCxDHcK zbKqx-3Dc(;*A*RetC`qJ`ym}IWJ{^`gKxKtc*-^8;4e)nZ>B9bv*n%3mj2sBgEH|O zUysH9l7bFlM9=hn_g7{qhts4_Q%@Z2^0$c!ZKojcj8&)%xcwfyqFMMhp_csJ_T4DX zcg>&VNSuwW^n?-WJCb$?7+Bi>i9#h{>5QrB=#B73H>}&smDy5@&qD&m$I{wTb z_LO)#%+M3Q`!EtHinXsl$FUiz)5ZiAU0TC$q2GD$-`q@JacWUBg)cu)>L5Wm|C637CYc`$2p2|hA4YZ+5`us_a;ho z8BE`CGrgVRvRKHB;*&kaC<6B*sH0bQMMknVHKe>J6?=C}LFrKA{eK)v#&6a422|PK7iqH^0Bd_uA&DXy~FR}Xo7Nsy+ z{#(LoVy`x^{_DSIzLSWogH1d>q~n7-R|w7er((Wm7)*F7#0S%_Va@MJy(16W;wBdu zAjt3kJwKD5=m>^DbF+yo7c#%KhtUUJljim`ko)~7>{|Qkn>+j0JGni5D!mZfO5=YM-Ak+UL?#y%tAl$FHTzvGh%`yI*;N|IQj z{g|lQbTn&01ugdCH<1CLWwx?6(GYVX-OeJ@$vI+mZFgGn~NIqFUaPCymy>kKc5-=y&K^ zgMjNZx>?t%Fcl(|#Y>4mj9ruCe6R;ogoWX1SFlFkPfYr@K`gslfEI4%z<>ox8Z73S zObA4#zp<|>2S+(}LKqZYc+Ell=9&sf@`rLe%P!jk%m64FtJk8U1*n_=+NNV60*2L@ z>0kd%QUC_^V{rVKUpw=LiODmUKXE}YuR?SCp>9M0b*3=hj^bJ1;8&U8L=l>LVGVkW zS+eIBbiXU=zK{N~d|uu+7vIrjV9Uh1S^|#jdkd9;X^t~6o?An~;79AshR;GM5-F?= z)bb3zR}m&ND>FDL$pBfPzssF))lkhjUmp2dC<$W(d$QTVg5==cXKDXFc-O1P>?{&U z&(p|;57QrD9AK|yp|b2QajHpRE_m*8WR!>lnwO&^OE}8ZTW(Y8olgSPx-?Rf^K;(S zQ6`dE|NcQ=IoVwBA6@%4ez83Tl2o9-Mx?20!|Lb`dCP9SdQktkeQ3VY60_3vGT&B0DH@M~qmJ_@t9cH*SnpSTWW2DQ%+Kzq zryWYsVnkB=GZV_9Nv@LT+u}%j`kjsBXL3%ykDhkvVIqD;3DMf`?J$2#NY2U6*@Ctv zfqUK&5i-BTSMtYfFkG-+S-J1bVVHvA=4MA4q?k`BrMRq;$qz0qp?i8C`bi^KoHYvV zb$s4}ZsV>T1@n?#j*On|Q=oVto32 z!y_)eKY$3!@u@P@*k!^qHD#6A=I~QMcBuW%!wJw4ov{{8lhLLxmFm_tPBk}4JkM}% zN-~&`PbfpTo8oINzTS@iYV2hZrUUV>uupy?9Dg7FQ!o{Tu48^CnBK_LTnd@s9L;ZA z@;wjsEOGjE+eFw__TldqbRle7{OxJaQ31nyJ%u;XcLy>_(c$2Du}0{|9gtQw=kg^z z|KFsMU01gKH&-1ko0DKT=zNUh8~?rD+HRw%vC1)gfvs&HEy8H=D2Q~8IA}Q82A6VE z)a}quh@sr)Rb7LAjxp3aI;-knIKE+}GG~eSu_Q ze9oga2Pw1n`vTVACOS;J#_7F=Y)071mcc_re!aZY#si zXXh@r828X6W)v4t4o$vTqr zxRoGidrg}FyhK_eB^^XL4uapOyLH9Vl>Z)n!!zrT+?6;EC6sP`>FzQ?t>vnf;g4)euSZyNYqHev_;P65kTgvN5wy&BsD)}vRH2%7ZnG)wgVN~e-n$vR z&^2Tocrt5^4jJtmTAQ2Y`aXJHDhfMSyS8mSpOok@f$rI6@XRWa+)n54Y|TU`%Qi4U zZ}ein#7FapDw#Tk=1=~Hg1?_ZE-%4Yb2``V;;-{2+(}!oHSE57`EKv&^|W)h>V?_= zK6>ql3B6a6K6iXseo1oas{jW-^KQZRxlq=QP_?(J(vR-LN?zc5a!&Wj0IW)k%N_pQ%Wd(NKE=b& za!-+B!0?57i43xRScxZkA&BJ>mhcW|dT_jvvY}+XBsqjq+w8=kx<9fAD=UUtdYkk6 z@bd~go81nmB6BBUvutHtPvFs(!CP&lHxOlw>`$M0j9xv>F_MacZZL&7fqMXkHLii?<`;2lBkYNTfJwprCl z^S&)vuL(lm++GJ!o?Z0*efE2rJ@FMKq|f8N7U;H3eN-hQ6XexS^Ws0&UFg`Aac*Y{v+fw0Wa2r=l=K6 zKM2H_7Oa#2H1 zz3-x&o9S!CRd3E!cn`P1S%}d0uC8U)EJlk&ER-2Vsv`4;DjGv1TbXNzw+G0n(M=4i z?aNBJX7Q`-uqEdM_#n*mJ-YbkZ#zuYq7*HaZpvTya=n7)!(2v>j@d~AaZHGw^{2ur zTHD{JpWv${^-^n^k1=m;K@`|&-PYrGVM!mrHa{o)#kKJJMsQchSbM=^lMXIubwh&C zdzGyHp=qwDKZa+V{S~9a9f&ttp^-{1i9O~fx>ESookW^9^!y&1$)xcf*g`+7Mq@va z!se)?Wr!zcs{aCS&k1GQ~>8NZUL;o&veP-V0AIQgm446oivLg ze>@dLK(c3I4^f=woRG~?`MI{L|2})%!k1dao&*eIj>!^6F#1&}&BUY}!NpL=_WRiy zddgot-ToaUt~?1y15ve(JYrnGG4#Pa3E_SZlI*roFmWH7qZUCtaTq)tNL7Z9X~bMt zeCZ_4$M?|(zee^HixI83L8LLf8TqDP(0zQ>SVx8 zuRSf>-4fTt&uCg;6P{ucnnkvFn495ONmw$QY&3aNx(vp6|Vgj9m-&{fvW%LjNqUr^e|FykGZK)+h8K%k|nr@(f87 z1)wY)sI~92*R3*uTJ@RTeB=Q!+;Sk~UB_bVZwAA*;SSf!WenEp0?YP|Yz@JUUb!!9ldan2={2A7#->2_(g|5&J z920PF8hNJIe7>kM$QiSnU*E)I^X<_nDQS)+)y}kzu+u()&~zFj*`AO`ix#qee9wd- zhkLnaE=D-T;fxgbyYjeLp9D-YYu|w@YWbz2qZQZ6d&_fI$7dQ)((E9YoVrKQED28X-rYZS-tKQ1w@B#Yo zdYgSD8_<+*f@k`+ua4h}ut}(#~g z)_6x(^cmqj9wWEMREjO3st=juDDKJX(kb;Baec-Gl0hU$`ZY(zxiw9dgZEA@GRwnL z+z7qNj0*^l`3wB3`G=1hTkvJsW?@{kZTH9%zDdXRS)rQm4jA|uIxU>G*~+%>LuOs! z-w=I`o(63ppt|6Meeya)cO2zb6~FDUQGbz5t^2$b9GXhDjn{vMG`yhxD_ZD1#(-ni zJoKITK2;ovEG5@|e^o)fGM|Cy0+{a!G!&q{w}$R-aNjR}hfS5BoL7CIG_* zqD)Jva(Yec4R-*oJ1@dAp~8U=k5r5?qzj|Ru0x~y`_j)D0vJsZH|r;Qj5S&QYRcTn zb2>|&J(o}RV{MyG=862fh3;q+gM>QGc997we(z5SujO4qr3q#FvzSP!`5pMqM!&nU zAM&P!9Q2IjcCK89*WY74unN?!J2>J*GX8!;Y9W}tb>bFdHartqJiWHRf&Aa&zi1bW zg~Jl$?Wt}U*}gpi`!??}T;%U4sSP^kQuX?-GKzw@?wFh6H4Grmrjc;pSm%Fv9TR@xQRRzZ}GGr&lnzqE_q=DL)$%IrYvdgMoO6L~?D_a0*=t+XJe32WLK zw#tIub4)BBauM|GY-szdFCbjC(OXD}*fP19AjE^+kSBegeXdmU{Ki`X&YCpd{Hfd? zF3ey|&R;c%abyrIgR@3l(w7GOr`Z7|F)x`97Ssta_+lmL-Ys`4YU_J^`zZT2BL>^M z#9|Ef8a;H>5wi2Ym>%?ueOKya7f?QWW@=uVi`krcp#+z=7Pg3{4f;LNf+qjv4}OFttoqqW z=d(v!y4`^V`IY6n7U|tvna`e?@Q;T>5y!nF{UaBU!Sz zFKM*rXGo%!mUic7Ma$rWOvag&DU?rAoN?%zPk^#X7D9}hQH6~ct}aYU58rfgQ>>%%KGAQnb0h9 z5qxdmJ{dLwguV25vrPf0NuPp-&Ub2HiYA5tSeH3YsVevspJH^Uyv3IBUnY>5FWz|J zqFx!Le>oZSXQu1-)XDIAMH>^O+{xV7DybI831&v*+)>Yy<$Uk!K$i$2r!`X2R3iF2 zs8VQHJF0$PAN}AZL9?M`VN>dIv;WQ!!}L9X$FbgDERd6t`@V@;A6_^9y5pCR{bLW> z(C+s;?j>UsEdYse)@A2FfNEKD&S{;$Pv1w|s-c2tPkt+(WgendK|@^+W=T=HjuIf~ z>YgQ+ZzFF6F=Vi3>(1|)KY@%(d+Ue%#r8rqy$Mw{$^y5dzsY2hL{0(@iZ+@$z1`6_>eII_Xxuq-jm%D3cs0ZKDWq_!sE#DK0Y6WNV%KBzt z_Igiy=&9|38N%@IUsxbplnb2yrk`Q?!-L$jw)^a;WB1h=wdEz|*4nNB&Fw1t&!1Or zkkftkefVK4ddFU)x@`m&DCV4N_{!%tSAh-ML?qusv85^3bf@8N6!5rtWVvGjxjPLy z^eN2o9hK!gCbOgz-Df#Pfi6Hr;x*^+X&c(TpXk zvnB)4mCNs2Jy~rZf%doiQzEA2bTY2h4`wMAkKyUZ7tu?gV9^WK)v|?96?5RmN4R}G z*jd+}`X{VvaFH|Tg35sYjhca$`wzx^HTgQMU@D$(gXUT~3o#vvEka{g+1#5i@R3|K zhGTxt*6hbRidy};hmEuUtJC-VQCGyfN~R>P^dpNqr!+tSM{!uUfzJi>eGjWiTM0`U~${x7azql8aL&csggjhue_J z+s5X_6!Mlv_6k%wR%x;Sk&*TP$K5y|NL{N1@(%jje@u%jY`&`=HCO;pSOgu-q@ zK8~D)e9xIB@G&(C_YX#O^QCii16Ki1^onD`i#<=+@mYb(QFcr@*W)YZP3vBOp zLPEx;A&lS%o&Y4JE_$btxf*veR!rt2=+0_zK-Wl*LTOCJnqnl5$l5s6FDTji>%5Q2 zh@%j%=DUX#qXRfy#Kv3mTF6=2njBD*W}U{uNwsxB5qO8U=RmF!-?^;9d=tBWuWJ-0?-!Nb&fB+?nthj?YkTSZ z>H6gw078=2(HF1bynnB2sIVD1dQA$7K0mwUUe%w5WqoH1f66?aub|SXh@@9WK^a^&N!n!D)@c>E5_EdLll_OmY8yp2x+Zi$U}NgT3gJ} z&kTAE7M1)306I(Q2sVzI&!TO>``&Yk^C4x=n+XfLXDP(Az|snWBwy z5`9Syk@^8s&U_Ajm0=}ggWyU2o-fo4M@@+fS#Q=9_J~B+Ujs5ACe~)!SxEgag`sV{ z`gGYQxycuCA(r1mNw@lc+gM7rXu`L2_mROtMNHf3V6>39woe3KPIAz+nN1$SjJ31B zf4{qFt?~q4&>~Q)E-ACmRt@jp0$~zIw0SA#l1|Km+v9(xSoq}Fq83bz%e1&&+Di`^E@;H4mHQdWbY$!OC4kcyR%`PTaQeehG(L~kVp6dNQNWn3FP?t^l4 zZhjwq(rm8)C7beV^jEZTWHSUD6GM~e(GaWfiW-!ow7cq+EZ|G8S`ztp&;jbHH6t}m zKr0l__B{^7cY<>bi_GlkW;OuQOWm|L0e*Az2^DyXBED>jzmGm%MU@0v_6*&rQADRp znf3Fnk+*YLPDiDrkG`aEXK$bktQbSQIYBJf;luR4x2tu{RgJx!#C%Y~eLU>$Q>`zg zeQ}7(AjNMEg7ck-XT&uVB7Bj*C#DI=TIswOz}V=sA<8k*^_i{xiiw@k?SQh2eKt8I zD~ZL&hUL)iZ?8bg;N(TV^G7V>yniz4utXLp*&~BRT*uCo-9-58S>1xoHDDS_h1@?H z^&Xmg4|QaAZRG9dyEeX&uO!N{m<?`EVPQ0E_$qi4Q1BvT-nYcA#$7fu~LV|qpUQZhi69~Z>X;7Qbf z=jMNIOv~ZjL!j)TAZawZ#qom8oI z`JW`1T8>Q0#AjAEl<>3%TDxhgwjtWBnf#1^%iI=x3JILT$K!NF-$@kmefDfmchlZA zMtM56aigDc1^0308o0oeYfDGN;Uz}C*gV`llOVCan?uHD4s0h-!u^$&wx8*f|4L;= zdOm?3R$ff2i8LJ6E%(=0CM8^>V3eoz{z*70M++CXVyqh&D@TdpFpc{-Z0Hm1EQ6ud z<g4X{FnQO=0h@UG_i}v39A|EFfCXjbPg%fwm6Ye=^MMX&9%@P`D0qf@FY|xI~q% zydFCk?D3x>xQznimeIYFb4XlB$u}VIdxjLaQmtKyLvfF0hQK<-%CY*ta6sA*-J$1? z2KYRj(RxLHHi~F@Lzi1XDl{Eo9(Pc!t)L0&<*Bn`3T^prMaTTTr(LMK%le7> zLhh>(a?^{8#L3-e7RVn@v2%UR5;ndg(<@as27`k<@_RZg_4t@TAnwvg_wr`(DN04CwW1-~yJ?{?g7*y`2fLP_g+MZy1fC=>r3XQ5~ zG=I8AU15|0Sc|1U=7xG?2f89+>!T$%u`NVd3!ck>co!h~PVKR#lICE&73jf#syvs( z5Zq{|QKZ2cyXpH!@%-H;$qYf3j{bbdHGUPb8*D`av$Bk^GQm*zUDHSwQSL^#I)b`b z4ZsEc{T`b!eMCDF8(}_~R7KssPtf9Wex4oSGb$mM9)G{xcnmSy(ig7 zyfJHIIQ%|-Xe^IJdQ&Wk;5q(&m%{<`J;H8sN+1Hnq032b=;e(C~( z-pndwr-f6}Y9XCBN}dfue&*o%vN4w76+XRFJHWBOX4M1T$jXJWp6k384Ud=go!Q5CJI6?ez z3qG0M)>t-c`#mhI+(Qso9M!mNhezMdB5p?iEK_4 z1E}+hAit8Y+5c=UMjZDJPW9(Hc0{Lkv1#^e19rUvF#P^gqz6stYm$yUWitRSldH_A zGnbQnlJ-_Qf*im4;9p6@xz?rp_q?}S6;i&ZvRAJ+WIPIktjXc;nSGBS*-- z_na|VG6-zPmn`|qwy-ek2kx?J%Pd2l-}3o8=n9+|0v}ZT{UrztqL0l0LXtOgX;UFO z<4iAhKOcWV0tPp(u~_Zu@B0(}Lq&JbjB%J0_Ia+8URC$ro;CSw(_r@seoD|jn%kC_ zewp8El+JMC(~D^{4GDnh56Q?)U-LaM<03~?q>z2!wr3A>b#YtAO(2`Vx{!{#jq4i{ z*fTRoJfEk4*mM_}tc~gHd&Bqr{$dqUEVCh;o3ZF2pJNb^|U4P(1@ z42bt&myX*8t)bFVbnhcu^4sfLd|OYp35D7=X1{kD{Ush8n2=xS`|w??C|N~7rDSVo zfUaF}_D<3*u9d+ac+(gq`LGQtauW`-&E;}rBQscTpfKcc_tfY0%qjJL7Ula-|>V0Q+Pff7s8!tmA+gv6=KSH3l&_qG{F|+9_D>%gZFP@ zHOyM8dDF2l84(xBDY&OKdNL`vwP5|yyP124tl1Jzc92RwgpkCjJQGPC=1(B`jl{<^ zVTJQ#D*ZysuFlUAZQ{r#F|Wu47i!jbjYj_JrpfL?gE~kK5(h_G98Sweb53G6%FhpT zvT{>PuVbUCZ@u+`+JB~q>+^i@sH+RDStHsjT0LQ=aZg(uBr1qczvWj@R_?TOV1KsY66~lZ9!n+EhiyM(Z%wV@wBG^M?kp0OH!3!65qcnRNXVn z@y9fxmOHF6qJtpgax<6~F*EM_%geQkK<{`>SZ3NCArBFU99DnhrlYtkHb-2Mbi%HI9U3QkWDZ3e0NUy0v@ znCFvPyc=rEN_N10%*1gWrBEGX|Pen8tvQN|R zFz44qrHM32R9RgXn$Xb#u@d(`^P(3q8GwRZ} zTT@b?Yj~sj<%|PCCp=md3M0k93c<;f-Sk~il^cOC%*q)}bE_Ht`iIZ5DJLk*#CW?? zd{#~QYIi0v80Q(mNVk99>gT(qQz)Rb?w?}5pYd<78FL|w?T8rI_>`9LIGZSWuI;G| zg?zivm9M0mUZ(GcYA`3sQ%|pH@%0GJ1feMlY5!wJ)qJXUu34r#A*|d90JplV18Ysv zkyY}Hdl9x=e|VjJQATR^h=uM@xf-j|AbCzV_N z%pZ+R-DRx7i1|k}%i;$#6m*0gN9xA4%oFy{ex_x^Qxr_G&;D)@9lqKaaK9IfRY;At z4p%RT?_$MMXQ;u^9`Pn`4N@Vf#yS#K1MX`rTXcMv+zN&>S>=aBU?}CCG1*M-OV{9p z31~S*ye3$;l4Ky6jhEK$IVS+%M%P)fZWA>GI!@rbL zig9nMY>!!66N8M_?r6-paUUt9VXccx>}zszc;tYMS9ns(>!`TGg`LTh#}Y8=;| zyQwWVxF?GUqrs2&M^&KowV-O+ac6+AQ%AubBN`(z;j4MTQ^-vT>ak-3zF$?;YI|Fl z=et?&dRnQG;iFAV#`aM>`R|xICs^9hfY6XAgrlId+cy4n$dgX@Dhi}rm+^ru&)EBD zq3?841OHpmX z=N`Gz#c%(+htWl4ye*LmOimTy^gh40>ig`iThUdJmR=IuKsh+NB+B5#BP_EsD?{#_ zxm;3SGC7)tvhT25YRMgiANcCwZGdt5QZZ2{kK%CnfA>$Vmx6k$Q8$~!EFkE`?V43F zTqHI#c>W*9ej5Evr}?N~HRFTQ5#R)BOaKy~Z5xZ8oXvNJfPyg-K0T8c@I;NFXf~mp z{miePBa?)P*aigDk+$-A)XvTOdY2LM=;B;ZCwIoy@)jfQ_ua*utAWRdIld3yOqicp z#mX>yk92@40R7Ckp>ctY`ZbQ4A!F0}?F^Ov!s|H-$T&l+L;Dxevu;gLz!ly(kSH)n z^M4=pt|$c{Q53)3fl3;Iirh;=Y3?=sl@YjC+~xgI;O`Jjc^&es02|D#L!30t%Ta8* zIiQ8dYY4NgpPy)KBKrGx86eF%GvA#Wph3G6Yc-<%n<`l0TViMb$Y7yUc#6no(l48J zYso||yH5BsR7I~&i|X%*9{^J{XTu8#7#$;_of#J1iQP`?Yu>3N3^~TsG76=Ht7zGXi&mWp(tbAWSg!1U z0%jpD{iW_9u2WUoz(|l^HGY&eWH+%IeCbAeZxE8TotWY7@B2E+ zSvg16k-_=}Y@bpDIfLiFbeqJt+8kphaDc6~tqPHX{LR_!N31EYLV`U;Rg#LQg&P5{ zq&IaRMwB?1Oq0g`#!VCiza!=QmL&Fj zrWG*XM?a@XBuV$z{yiSgG_3Sxl&Kal9Wnv%Cn;=RWz7QeN8afcktgstu}s5xn%S#J zbH)4dj(M)gT<-c?RG%LrlRQYCqxl$eX!Qeo-CqsPZt$4aV3hpf_oqH~WEQZ9^*HwD z9INoUOd}PNt(eX?^-OOe2)tTVbjDIAT{O0Yv9K&(l$Lz;7~C`){fbBUP}<;+TMO<; z6P4X1$^}1ugILGrGGW9(d{&2~3x7{d%>`63p|#_10HY*Q23yG1pN}=7C1=J=jD>FS z2i#3LkJ0QMDfX?7zVe*?AL;pbfsM{{S4vwtHe8uRTvxGL!8uT~8MOZ`Jy%c^bZ)><+(k3;AjWsUsPi;)vRQA;~8(_U@ESo)Pe+95yM>n#q#iZhac^q-_2ZCmU zvKPIlY@Ehv---*>I4`rTM*n;Zbblsz`z%G{%?y&Xz;r<^g~Vjs*;FT+=B`0L&cE+H zE6NtNUO)By3$q(R2I<}=@Cj<~_xclv%tSI(qC@jWFKg!qucqO9I>6YoWPQQi<0uF6 zRNRn~p9$utF;r6M+yuXsut|BE$#|c~+7zuMbq&NfO&y9cC0{rOTb&VCmDXe>>&D7D z_x8VVeJzvrR#Fwd0ebt`;_x=K0_XWXSCAzm$D*Q5J)9O6zNq9}?*u|b0UMy79vXr; zP&hjH_p?)apq@1L%il5cO}x~nrstOQ9WzOYy@ba*mT$u6pqG-*nz#CsI19D{ztf+3 zn_@v5H}#dC>8R{##`f}17A;L2woeRtw)X~poG@IbfA!EBRk?G9Fh7U#HfZnfvu8#j z_;s%x*^0VXF*kmfSSrmVM6XJkV2RnNS%MCTF6jaJp0PzR(YXQ)t)J=8 zta)~(ySU0`vQPbTjg8on{}qQE7@s!zPNz-q@kHx_v%kJipHM|v(B=!?W1=&yzi7xP z1v@4KkK*F&+f*yY?cQL40#7T{vtv>(=Mip-^VH(5HHK}86>pbU80?|$i^wgYAWh0K z4}44Z71n*k?MG56Gqh*<``$B%wgk;ObIza<%y%ygZkaw?!)|l9VjuX4=kw-RdkzBO z08-taRI%qs`0?q%?Xf+#QK&twt?WYfPb-h9u>kpWF=-mzo#jrAv(i$~rqV_EJbzz# zFJV4mIt44gi36X^UU4-OY;TkzQU(v@FWuU_=1prgSX|4g^tSv<;Ri$_|Gxc;bhcy2 zeEg~``dU5feMiDOZR&32(R`Dv7urb~mgd@r&6M_i@(-?mmybv-HL&sUnn!5#*`?<} zK^#ojJjXv+Xj=2D1GGqvl$haXt5Gg%?mO4lSfB{gWCGq$aB5=!am z*NBw^(BTJU4d;9PdXBhD9^$L(axdRS9|s>6l6%``Rb_R9=q;b{nr=Z&8rk4jCgF(9 znBvnPW}SCoYYsEiNIxS9GTxza(IlrDCepqhiB))4jc66ue}3@|3hPoY8$f;Ifqjz5 zu%}9^x&Cg{&2=f5FZ1d|VZx?(b&fW$M(isn$`zrjll>qPzP6lf11xKg8n+|Br$Vco ztMd6qCWXAJFoS*FBJJM1nMvc9Z_F!#%(6=?@89{dk9tYVwARNg&!>bC^Q?+IzvviR zAA&)DkOAz}oWk!?0;NoH`-q!Gd`*l~9JQJH?=26;dz-RaF=_!usPgJ#ndD`W799}F zV9bIU7%${s@7k<>|Ju}KRjh;CM6HS9sKb8i#l69AL~;=^7;g&R8Pg2_!spyZK|=tv zlwp8gJ#~ypM~~v(*Ip}wB>Q_H&f)J9s*x_B9qyc}-Lw(({hw{E$V^^@GC9n@`*a`C zDRepykezq;{8B#TQ{HP$n$=24B|P!lhk-W**lUjNF(MuePFImK%LE)@IG9f7*q=J) zAV`4MTY5STeJS{_N`fuF3NHmPI^ci}c_J)S{HuySc zi)1(Yl#4!Z>N(+g@g{&mO|fVBMNsu1&dhT_1b*$Q#CTCl4L49mI!Ec