From 45d80dfaa6218f060c9a21d6447e52b4b2fe5ad8 Mon Sep 17 00:00:00 2001 From: erdemerikci Date: Wed, 22 Apr 2026 15:13:08 +0300 Subject: [PATCH] Initial import: Lightning_Report with n8n integration Fork of Lightning_Report adding: - n8n_report_branch.json: workflow branch for storm-triggered report delivery - report_service/: FastAPI microservice wrapping create_docx_report() so n8n can produce byte-identical reports without fighting the Python Code sandbox Made-with: Cursor --- .gitignore | 9 + BATCH_GENERATION_README.md | 145 + README.md | 646 + README_UTM_Converter.md | 181 + batch_generate.py | 463 + logo/earth_networks.jpg | Bin 0 -> 76496 bytes logo/iklim.png | Bin 0 -> 19755 bytes main.py | 84 + n8n_report_branch.json | 197 + qgis/2025_07_dagpazari_RES.qgz | Bin 0 -> 17814 bytes report_service/Dockerfile | 36 + report_service/README.md | 97 + report_service/__init__.py | 0 report_service/adapter.py | 178 + report_service/docker-compose.yml | 39 + report_service/main.py | 141 + report_service/requirements.txt | 2 + requirements.txt | 10 + separate_by_month.py | 100 + src/analysis/geospatial.py | 51 + src/analysis/grouping.py | 93 + src/analysis/histogram.py | 363 + src/analysis/risk.py | 153 + src/analysis/statistics.py | 208 + src/api/data_fetcher.py | 275 + src/config.py | 92 + src/data/loader.py | 205 + src/reporting/docx.py | 1114 + src/reporting/docx_sections.py | 236 + src/reporting/filename_utils.py | 133 + src/reporting/gemini_commentary.py | 209 + src/reporting/precompute.py | 86 + src/utils.py | 480 + src/visualization/maps.py | 1111 + src/visualization/storm_cells.py | 665 + test_data/dagpazari_RES_coordinates.json | 77 + test_data/deneme.pdf | 834 + test_data/firtina_sorgulama_2024_05.json | 1260 + test_data/test_report.pdf | 593 + .../yildirim_simsek_sorgulama_2024_05.json | 138610 +++++++++++++++ utm_converter_requirements.txt | 2 + utm_ed50_to_wgs84_converter.py | 262 + utm_ed50_to_wgs84_converter_enhanced.py | 340 + wind_farms_config.json | 184 + 44 files changed, 149964 insertions(+) create mode 100644 .gitignore create mode 100644 BATCH_GENERATION_README.md create mode 100644 README.md create mode 100644 README_UTM_Converter.md create mode 100644 batch_generate.py create mode 100644 logo/earth_networks.jpg create mode 100644 logo/iklim.png create mode 100644 main.py create mode 100644 n8n_report_branch.json create mode 100644 qgis/2025_07_dagpazari_RES.qgz create mode 100644 report_service/Dockerfile create mode 100644 report_service/README.md create mode 100644 report_service/__init__.py create mode 100644 report_service/adapter.py create mode 100644 report_service/docker-compose.yml create mode 100644 report_service/main.py create mode 100644 report_service/requirements.txt create mode 100644 requirements.txt create mode 100644 separate_by_month.py create mode 100644 src/analysis/geospatial.py create mode 100644 src/analysis/grouping.py create mode 100644 src/analysis/histogram.py create mode 100644 src/analysis/risk.py create mode 100644 src/analysis/statistics.py create mode 100644 src/api/data_fetcher.py create mode 100644 src/config.py create mode 100644 src/data/loader.py create mode 100644 src/reporting/docx.py create mode 100644 src/reporting/docx_sections.py create mode 100644 src/reporting/filename_utils.py create mode 100644 src/reporting/gemini_commentary.py create mode 100644 src/reporting/precompute.py create mode 100644 src/utils.py create mode 100644 src/visualization/maps.py create mode 100644 src/visualization/storm_cells.py create mode 100644 test_data/dagpazari_RES_coordinates.json create mode 100644 test_data/deneme.pdf create mode 100644 test_data/firtina_sorgulama_2024_05.json create mode 100644 test_data/test_report.pdf create mode 100644 test_data/yildirim_simsek_sorgulama_2024_05.json create mode 100644 utm_converter_requirements.txt create mode 100644 utm_ed50_to_wgs84_converter.py create mode 100644 utm_ed50_to_wgs84_converter_enhanced.py create mode 100644 wind_farms_config.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0034f34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/distance_analysis.html +/firtina_sorgulama_2025-07-22_2025-04-01.xlsx +/turbine_report.pdf +/yildirim_simsek_sorgulama-2025-07-22_2025-04-01.xlsx +/~$yildirim_simsek_sorgulama-2025-07-22_2025-04-01.xlsx +.env +*.log +__pycache__/ +*.pyc \ No newline at end of file diff --git a/BATCH_GENERATION_README.md b/BATCH_GENERATION_README.md new file mode 100644 index 0000000..2282081 --- /dev/null +++ b/BATCH_GENERATION_README.md @@ -0,0 +1,145 @@ +# Batch Report Generation Guide + +## Overview + +The batch generation system automates lightning report generation for multiple wind farms by fetching data from the API and processing them in batch. + +## Setup + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Create .env File + +Create a `.env` file in the project root with your API key: + +```env +API_KEY=your_api_key_here +``` + +### 3. Create Configuration File + +Copy `wind_farms_config.example.json` to `wind_farms_config.json` and configure your wind farms: + +```bash +cp wind_farms_config.example.json wind_farms_config.json +``` + +Edit `wind_farms_config.json` with your farms' details. + +## Configuration + +### API Configuration + +```json +{ + "api_config": { + "base_url": "https://risk.tarla.io/api", + "timeout_seconds": 30, + "retry_attempts": 3, + "default_query_range": { + "method": "current_month" + } + } +} +``` + +### Wind Farm Configuration + +Each farm can have: + +- **enabled**: `true` or `false` - Controls whether to generate report +- **location_bounds.method**: `"auto"` (calculate from turbines) or `"manual"` (specify) +- **location_bounds.padding_km**: Extra buffer beyond max distance ring (default: 5km) +- **date_range.method**: `"auto"` (use query_range to fetch, then detect from data) or `"manual"` (specify dates) + +### Query Range Options (for auto date_range) + +- `"current_month"`: First day of current month to today +- `"last_month"`: Entire previous month +- `"days_back"`: Last N days (requires `days` parameter) +- `"custom"`: Specific dates (requires `start_date` and `end_date`) + +## Usage + +### Process All Enabled Farms + +```bash +python batch_generate.py --config wind_farms_config.json +``` + +### List Farms + +```bash +python batch_generate.py --config wind_farms_config.json --list-farms +``` + +### Process Specific Farm + +```bash +python batch_generate.py --config wind_farms_config.json --farm-id dagpazari_RES +``` + +### Process All Farms (Ignore Enabled Flag) + +```bash +python batch_generate.py --config wind_farms_config.json --force-all +``` + +### Process Disabled Farm + +```bash +python batch_generate.py --config wind_farms_config.json --farm-id disabled_farm --force +``` + +## How It Works + +### Location Bounds Auto-Calculation + +1. **Calculate Centroid**: Average of all turbine coordinates +2. **Find Max Distance**: Maximum distance from centroid to any turbine +3. **Add Distance Ring**: Add max distance ring (e.g., 30km) +4. **Add Padding**: Add padding (e.g., 5km) +5. **Result**: Center (centroid) + Radius (total distance) + +## Output + +- Reports are saved to each farm's `output_directory` in the config +- Batch summary saved to `reports/batch_summary_YYYY-MM-DD.json` +- Log file: `batch_generation_YYYY-MM-DD.log` + +## API Endpoints + +The system uses: +- Lightning data: `https://risk.tarla.io/api/lightning-data/historical/` +- Storm data: `https://risk.tarla.io/api/storm-data/historical/` + +Parameters: +- `queryType=circle` +- `centerLongitude` (longitude first) +- `centerLatitude` +- `radius` (in meters) +- `startDate=YYYY-MM-DD` +- `endDate=YYYY-MM-DD` + +## Troubleshooting + +### API Key Not Found + +Ensure `.env` file exists with `API_KEY=your_key` + +### No Data Fetched + +- Check API credentials +- Verify date range is correct +- Check if location bounds cover the area +- Verify API endpoint URLs + +### Farm Skipped + +- Check if `enabled: false` in config +- Use `--force` flag to process disabled farms + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1aedca --- /dev/null +++ b/README.md @@ -0,0 +1,646 @@ +# Lightning Report Generator + +A comprehensive Python application for analyzing lightning strike data in relation to wind turbine locations and generating detailed DOCX reports with risk assessments, visualizations, and statistical analysis. + +## Overview + +This application processes lightning strike data and wind turbine coordinates to: +- Calculate lightning risk scores for each turbine using advanced mathematical models +- Generate interactive maps showing lightning strikes and turbine locations +- Create statistical analysis and histograms with temporal distribution +- Group turbines based on proximity and risk levels +- Generate comprehensive DOCX reports with visualizations and risk assessment charts +- Support storm cell analysis and mapping +- Provide detailed risk score interpretation and calculation methodology + +## Features + +### Core Analysis +- **Risk Assessment**: Fast per-turbine scoring using BallTree radius queries (Haversine metric) with automatic fallback to vectorized matrix math +- **Advanced Risk Formula**: `Risk = P₀ × (1 + α×Current/10000) × e^(-α×Distance)` with configurable parameters +- **Geospatial Analysis**: Vectorized Haversine utilities and configurable distance rings +- **Statistical Analysis**: Lightning density, frequency, and temporal distribution analysis +- **Daily Lightning Density**: Calculates daily average using actual number of days in date range (not fixed month) +- **Turbine Grouping**: Proximity-based clustering using DBSCAN (Haversine) with graceful fallback to O(N^2) grouping for small datasets + +### API Integration +- **Automated Data Fetching**: Fetch lightning and storm data directly from API +- **Flexible Location Bounds**: Auto-calculate center + radius from turbines or specify manually +- **Date Range Management**: Auto-detect actual period from data or use manual date ranges +- **Batch Processing**: Process multiple wind farms in a single run +- **Error Handling**: Graceful handling of empty data, API timeouts, and failures + +### Visualization +- **Interactive Maps**: Plotly-based coordinate-plane maps for CG/IC lightning with ring-aware coloring +- **Risk Score Heatmap**: 2D visualization with current magnitude on X-axis (up to 300k amps) and distance on Y-axis, with contour curves +- **Fixed Interval Coloring**: Consistent color gradient mapping (blue to red) based on predefined risk score ranges (0.1-1.5) +- **Lightning Histograms**: Temporal distribution of lightning events with peak detection +- **Storm Cell Maps**: Visualization of storm cell data (when available) +- **Coordinate Plane Views**: Standard geographic orientation (latitude on Y-axis, longitude on X-axis) + +### Reporting +- **DOCX Generation**: Word reports (DOCX) +- **Risk Score Chart**: Integrated heatmap showing distance vs. current magnitude relationship +- **Multiple Map Types**: Coordinate plane maps for different lightning types +- **Statistical Tables**: Detailed lightning strike information with proximity data (precomputed distances) +- **Risk Summaries**: Grouped risk analysis and recommendations with fixed interval color coding +- **Enhanced Appendix**: Detailed methodology explanations including risk calculation method, interpretation guide, and algorithm descriptions + +### Data Processing +- **JSON Data Loading**: Support for various JSON data structures +- **Date Range Filtering**: Configurable analysis periods +- **Date/Time Formatting**: Centralized, consistent DD-MM-YYYY and DD-MM-YYYY HH:MM:SS formatting +- **Data Validation**: Comprehensive input validation and error handling +- **Precomputation**: Shared per-group distance and ring-index precompute reused by maps and tables +- **Coordinate Conversion**: UTM ED50 to WGS84 coordinate system conversion + +## Installation + +### Prerequisites +- Python 3.8 or higher +- pip package manager + +### Dependencies +Install the required packages: + +```bash +pip install -r requirements.txt +``` + +### Required Packages +- `pandas>=1.5.0` - Data manipulation and analysis +- `numpy>=1.21.0` - Numerical computations +- `plotly>=5.15.0` - Interactive visualizations +- `kaleido>=0.2.1` - Static image export for Plotly +- `scikit-learn>=1.3.0` - BallTree radius queries and DBSCAN clustering (used when available) +- `requests>=2.31.0` - API HTTP requests +- `python-dotenv>=1.0.0` - Environment variable management +- `python-docx>=1.1.2` - DOCX (Word) report generation + +### Optional Dependencies +For coordinate conversion functionality: +```bash +pip install -r utm_converter_requirements.txt +``` + +## Configuration + +The application supports two modes of operation: + +### 1. Single Report Generation (Legacy Mode) + +Uses `src/config.py` for configuration. See the legacy section below for details. + +### 2. Batch Report Generation (Recommended) + +Uses `wind_farms_config.json` for multi-farm batch processing with API integration. + +#### Setup + +1. **Create `.env` file** with your API key: +```env +API_KEY=your_api_key_here +``` + +2. **Create `wind_farms_config.json`**: +```json +{ + "api_config": { + "base_url": "https://risk.tarla.io/api", + "timeout_seconds": 30, + "retry_attempts": 3, + "default_query_range": { + "method": "current_month" + } + }, + "output_base_directory": "reports/", + "default_padding_km": 5, + "wind_farms": [ + { + "farm_id": "dagpazari_RES", + "name": "Dağpazarı RES", + "enabled": true, + "coordinates_file": "/path/to/coordinates.json", + "distance_rings": [1000, 2000, 3000, 4000, 10000], + "ring_colors": ["purple", "red", "orange", "coral", "green"], + "api_params": { + "location_bounds": { + "method": "auto", + "padding_km": 5 + }, + "date_range": { + "method": "auto", + "query_range": { + "method": "current_month" + } + } + }, + "report_config": { + "output_directory": "reports/dagpazari_RES/", + "wind_farm_name": "Dağpazarı RES" + } + } + ] +} +``` + +#### Configuration Parameters + +**Farm-Level Settings:** +- `enabled`: `true`/`false` - Enable/disable report generation for this farm +- `distance_rings`: Array of distance rings in meters (e.g., `[1000, 2000, 3000, 4000, 10000]`) +- `ring_colors`: Array of colors for each ring +- `coordinates_file`: Path to turbine coordinates JSON file + +**Location Bounds:** +- `method`: `"auto"` (calculate from turbines) or `"manual"` (specify) +- `padding_km`: Extra buffer beyond max distance ring (default: 5km) +- For manual: provide `center_lat`, `center_lng`, `radius_km` + +**Date Range:** +- `method`: `"auto"` (detect from data) or `"manual"` (specify) +- For manual: provide `start_date` and `end_date` in `DD-MM-YYYY` format +- For auto: specify `query_range` to control API query period + +**Query Range Options (for auto mode):** +- `"current_month"`: First day of current month to today +- `"last_month"`: Entire previous month +- `"days_back"`: Last N days (requires `days` parameter) +- `"custom"`: Specific dates (requires `start_date` and `end_date`) + +#### Global Configuration (src/config.py) + +The `src/config.py` file now only contains global defaults: +- Risk calculation parameters (`risk_params`) +- Histogram parameters (`histogram_params`) +- PDF layout parameters (`pdf_params`) +- Grouping parameters (`grouping_params`) + +**Note:** Farm-specific settings (distance_rings, ring_colors, wind_farm_name, file paths, date ranges) are managed in `wind_farms_config.json` and should NOT be configured in `config.py`. + +### Location Bounds Auto-Calculation + +When `location_bounds.method = "auto"`, the system calculates: + +1. **Centroid (Center Point)**: + - `center_lat` = average of all turbine latitudes + - `center_lng` = average of all turbine longitudes + +2. **Maximum Distance from Centroid**: + - Calculates distance from centroid to each turbine + - Finds the maximum distance + +3. **Total Radius**: + ``` + radius_km = (max_turbine_distance / 1000) + + (max_distance_ring / 1000) + + padding_km + ``` + + Example: If turbines span 2.5km from centroid, max ring is 10km, padding is 5km: + - Total radius = 2.5 + 10 + 5 = 17.5km + +### Date Range Handling + +- If `date_range.method = "auto"`: Uses `query_range` to determine what dates to fetch; the report uses those query dates for the analyzed period. +- If `date_range.method = "manual"`: Uses specified `start_date` and `end_date` for both API fetch and report (supports `DD-MM-YYYY` or ISO with time, e.g. `2026-01-22T07:00:00Z`). + +### Daily Lightning Density Calculation + +The daily lightning density is calculated using the **actual number of days** in the analysis period: + +``` +daily_lightning_per_km2 = total_lightning_per_km2 / actual_days_in_range +``` + +Where `actual_days_in_range` is calculated from the start and end dates (inclusive). + +**Example:** +- Date range: September 1-15 (15 days) +- Total lightning density: 150 events/km² +- Daily lightning density: 150 / 15 = 10 events/km²/day + +This ensures accurate daily averages for partial months or custom date ranges. + +### Risk Score Categories +The system uses fixed interval coloring based on specific risk score ranges: +- **Very Low Risk (<0.1)**: Blue - Distant lightning with low current +- **Low Risk (0.1-0.2)**: Teal - Moderate distance lightning +- **Med-Low Risk (0.2-0.4)**: Green - Closer lightning +- **Medium Risk (0.4-0.6)**: Yellow - Moderate risk lightning +- **Med-High Risk (0.6-0.8)**: Orange - High risk lightning +- **High Risk (0.8-1.0)**: Dark Orange - Very high risk lightning +- **Very High Risk (1.0-1.2)**: Red - Extreme risk lightning +- **Critical Risk (>1.2)**: Dark Red - Critical risk lightning + +### Grouping vs Analysis Radius +- **grouping_params.max_distance_m (meters)**: Controls ONLY turbine clustering (grouping). If set (>0), it overrides ring-based grouping. Used to decide which turbines are in the same group. +- **grouping_params.distance_ring_index (0-based)**: Selects a ring from `distance_rings`. + - For grouping: used only if `max_distance_m` is not set; determines grouping radius. + - For analysis (histogram, stats, report labels): ALWAYS used to choose the analysis radius/cutoff. Does not change grouping when `max_distance_m` is provided. + +Examples +- If `max_distance_m=2500` and `distance_ring_index=4` (10 km ring): + - Grouping radius = 2.5 km (from max_distance_m) + - Analysis radius = 10 km (from distance_ring_index) +- If `max_distance_m` unset and `distance_ring_index=1` (2 km ring): + - Grouping radius = 2 km + - Analysis radius = 2 km + +Clustering Algorithm +- Preferred: DBSCAN with Haversine metric + - Convert lat/lng to radians; `eps = (radius_km / 6371)`, `min_samples=1` + - Clusters are formed transitively (density reachability). Example with R=2 km: A–B=1.5 km, B–C=1.5 km, A–C=3.0 km → one cluster {A,B,C} due to B bridging A and C +- Fallback: Greedy O(N^2) proximity grouping if scikit-learn is unavailable + - Starts a group at turbine i; adds any j within R of i; moves on. No transitive chaining + +### Wind Farm Configuration +```python +wind_farm_name = "Your Wind Farm Name" +``` + +## Usage + +### Batch Report Generation (Recommended) + +Generate reports for multiple wind farms automatically: + +```bash +# Process all enabled farms +python batch_generate.py --config wind_farms_config.json + +# Process specific farm +python batch_generate.py --config wind_farms_config.json --farm-id dagpazari_RES + +# List farms and their enabled status +python batch_generate.py --config wind_farms_config.json --list-farms + +# Process all farms (ignore enabled flag) +python batch_generate.py --config wind_farms_config.json --force-all +``` + +The batch system will: +1. Load configuration from `wind_farms_config.json` +2. For each enabled farm: + - Load turbine coordinates + - Auto-calculate location bounds (center + radius) from turbines + - Determine date range for API query + - Fetch lightning data from API + - Fetch storm data from API + - Calculate risk scores + - Generate DOCX report + - Save to farm's output directory +3. Generate batch summary report + +### Single Report Generation (Legacy) + +Run the main application for a single report: +```bash +python main.py +``` + +The application will: +1. Load lightning and turbine data from configured JSON files (in `src/config.py`) +2. Calculate risk scores for each turbine using the advanced risk formula +3. Create turbine groups based on proximity +4. Generate visualizations including the new risk score heatmap +5. Create a comprehensive DOCX report with enhanced appendix + +### Data Format Requirements + +#### Lightning Data JSON +```json +{ + "data": [ + { + "lat": 39.85420, + "lng": 26.71218, + "local_time": "2025-07-15T14:30:25", + "current": -15000, + "p_type": "0", + "height": 5000 + } + ] +} +``` + +**Required Fields:** +- `lat`, `lng`: Lightning strike coordinates +- `local_time`: Timestamp (various formats supported) +- `current`: Lightning current in amperes +- `p_type`: Lightning type ("0" for cloud-to-ground, others for intercloud) + +#### Turbine Data JSON +```json +[ + { + "lat": 39.85420, + "lng": 26.71218, + "turbine_id": "T001" + } +] +``` + +**Required Fields:** +- `lat`, `lng`: Turbine coordinates +- `turbine_id`: Unique turbine identifier + +### Advanced Usage + +#### Coordinate Conversion +Convert UTM ED50 coordinates to WGS84: +```bash +python utm_ed50_to_wgs84_converter.py input.csv output.csv +``` + +#### Data Separation by Month +Separate large JSON files by month: +```bash +python separate_by_month.py input_data.json [output_directory] +``` + +## Output + +### DOCX Report Structure +1. **Cover Page**: Wind farm information and analysis period +2. **Report Summary**: Automated narrative summary (Gemini-backed when available) +3. **Risk Analysis**: Detailed risk scores and rankings with fixed interval coloring +4. **Lightning Maps**: Coordinate plane visualizations with proper geographic orientation +5. **Statistical Analysis**: Lightning density and frequency data +6. **Detailed Tables**: Complete lightning strike information with color-coded distance rings +7. **Storm Analysis**: Storm cell data and maps (if available) +8. **Enhanced Appendix**: Comprehensive methodology including: + - Risk calculation method and formula explanation + - Risk score interpretation guide + - Centroid and distance ring calculation methodology + - Turbine grouping algorithm description + - Frequent lightning activity period detection algorithm + +### Generated Files + +**Single Report Mode:** +- `lightning_report.log`: Application execution log +- `{wind_farm_name}_lightning_report.docx`: Main DOCX report +- Interactive HTML maps (temporary files) + +**Batch Generation Mode:** +- `batch_generation_YYYY-MM-DD.log`: Batch execution log +- `batch_summary_YYYY-MM-DD.json`: Batch processing summary +- `{farm_id}_report.docx`: DOCX report for each farm (in respective output directories) + +## Project Structure + +``` +lightning_report/ +├── main.py # Single report generation (legacy) +├── batch_generate.py # Batch report generation with API +├── wind_farms_config.json # Batch configuration file +├── .env # API credentials (gitignored) +├── requirements.txt # Python dependencies +├── src/ +│ ├── config.py # Global configuration defaults +│ ├── api/ +│ │ └── data_fetcher.py # API integration for data fetching +│ ├── data/ +│ │ └── loader.py # Data loading and validation +│ ├── analysis/ +│ │ ├── geospatial.py # Distance calculations (vectorized Haversine) +│ │ ├── grouping.py # Turbine grouping (DBSCAN + fallback) +│ │ ├── histogram.py # Temporal analysis +│ │ ├── risk.py # Risk calculation (BallTree + fallback) +│ │ └── statistics.py # Statistical analysis (includes daily density) +│ ├── reporting/ +│ │ ├── docx.py # DOCX report generation +│ │ ├── docx_sections.py # Shared DOCX helpers (charts/tables) +│ │ └── precompute.py # Shared precomputations (distances, ring indices) +│ ├── visualization/ +│ │ ├── maps.py # Map generation with risk score heatmap +│ │ └── storm_cells.py # Storm cell visualization +│ └── utils.py # Utility functions including fixed interval coloring +├── separate_by_month.py # Data separation utility +└── utm_ed50_to_wgs84_converter.py # Coordinate conversion +``` + +## Configuration Examples + +### Batch Generation Setup + +**Example: Multiple Farms with Different Settings** +```json +{ + "api_config": { + "base_url": "https://risk.tarla.io/api", + "timeout_seconds": 30, + "retry_attempts": 3 + }, + "wind_farms": [ + { + "farm_id": "farm1", + "name": "Farm 1", + "enabled": true, + "coordinates_file": "/path/to/farm1_coordinates.json", + "distance_rings": [1000, 2000, 3000, 4000, 10000], + "api_params": { + "location_bounds": { + "method": "auto", + "padding_km": 5 + }, + "date_range": { + "method": "manual", + "start_date": "01-09-2025", + "end_date": "30-09-2025" + } + }, + "report_config": { + "output_directory": "reports/farm1/", + "wind_farm_name": "Farm 1" + } + }, + { + "farm_id": "farm2", + "name": "Farm 2", + "enabled": false, + "coordinates_file": "/path/to/farm2_coordinates.json", + "distance_rings": [1000, 2000, 3000, 4000, 10000], + "api_params": { + "location_bounds": { + "method": "manual", + "center_lat": 36.90, + "center_lng": 33.575, + "radius_km": 35 + }, + "date_range": { + "method": "auto", + "query_range": { + "method": "days_back", + "days": 30 + } + } + }, + "report_config": { + "output_directory": "reports/farm2/", + "wind_farm_name": "Farm 2" + } + } + ] +} +``` + +### Custom Risk Parameters +```python +# Adjust risk calculation sensitivity in src/config.py +risk_params = { + 'P_0': 1.5, # Higher base probability + 'alpha': 0.3, # Slower distance decay + 'current_weight': 0.2 # Higher current importance +} +``` + +**Note:** Farm-specific settings (distance_rings, ring_colors, etc.) should be configured in `wind_farms_config.json`, not in `config.py`. + +## Risk Score Methodology + +### Risk Calculation Formula +The system uses an advanced risk calculation formula: +``` +Risk = P₀ × (1 + α×Current/10000) × e^(-α×Distance) +``` + +Where: +- **P₀**: Base probability (configurable) +- **α**: Distance decay factor (configurable) +- **Current**: Lightning current magnitude in amperes +- **Distance**: Distance from turbine in kilometers + +### Risk Score Interpretation +The risk score heatmap provides a visual reference for interpreting risk levels: +- **X-axis**: Lightning current magnitude (1,000 to 300,000 amperes) +- **Y-axis**: Distance from turbine (0.1 km to max distance ring, dynamically scaled) +- **Color intensity**: Risk score level (blue to red gradient using palette: F94144, F3722C, F8961E, F9C74F, 90BE6D, 43AA8B, 577590) +- **Contour curves**: Specific risk level boundaries (0.1, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.5) + +### API Integration + +The system integrates with the Tarla.io API for automated data fetching: + +**Endpoints:** +- Lightning data: `https://risk.tarla.io/api/lightning-data/historical/` +- Storm data: `https://risk.tarla.io/api/storm-data/historical/` + +**Authentication:** +- API key stored in `.env` file as `API_KEY` +- Sent as `x-api-key` header in requests + +**Request Format:** +- Query type: `circle` (center + radius) +- Parameters: `centerLatitude`, `centerLongitude`, `radius` (in meters), `startDate`, `endDate` +- Date format: `YYYY-MM-DD` + +**Response Handling:** +- Automatically converts API responses to expected DataFrame format +- Handles empty datasets gracefully +- Validates data structure before processing + +## Troubleshooting + +### Common Issues + +1. **API Authentication Errors (401 Unauthorized)** + - Verify `.env` file exists with `API_KEY=your_key` + - Check that API key is correct and active + - Ensure API key contains special characters correctly (e.g., `==` at the end) + +2. **API Timeout Errors** + - Increase `timeout_seconds` in `api_config` + - Check network connectivity + - Verify API endpoint is accessible + +3. **File Not Found Errors** + - For batch mode: Verify file paths in `wind_farms_config.json` + - For single mode: Verify file paths in `src/config.py` + - Ensure JSON files exist and are readable + +4. **Data Validation Errors** + - Check JSON format matches required structure + - Verify coordinate values are valid numbers + - Ensure timestamp format is supported + - For API data: Check API response format matches expected structure + +5. **Empty Data / NaT Errors** + - System handles empty datasets gracefully + - Check API date range - data might not exist for specified period + - Verify location bounds cover the area of interest + - Check logs for API response details + +6. **Memory Issues with Large Datasets** + - Use `separate_by_month.py` to split large files + - Adjust analysis period to smaller time ranges + - Process farms individually using `--farm-id` flag + +7. **DOCX Generation Errors** + - Ensure sufficient disk space + - Check write permissions for output directory + +8. **Risk Score Heatmap Issues** + - Verify distance_rings configuration is valid + - Check that lightning data contains valid current values + - Ensure turbine coordinates are properly formatted + +9. **Batch Generation Issues** + - Check `batch_summary_YYYY-MM-DD.json` for detailed error information + - Verify all farms have valid configuration + - Check `batch_generation_YYYY-MM-DD.log` for detailed logs + - Use `--list-farms` to verify farm configuration + +### Logging + +**Single Report Mode:** +- `lightning_report.log`: Application execution log + +**Batch Generation Mode:** +- `batch_generation_YYYY-MM-DD.log`: Batch execution log with per-farm details +- `batch_summary_YYYY-MM-DD.json`: Structured summary of batch processing + +Logs include: +- Data loading progress +- API request/response details +- Risk calculation details +- Error messages and stack traces +- Performance metrics +- Farm processing status + +## Performance Considerations + +- **Large Datasets**: For datasets with >100,000 lightning strikes, consider: + - Using date range filtering + - Splitting data by month + - Increasing system memory allocation + +- **Optimizations used**: + - BallTree neighbor queries for CG risk scoring (O(n log n) build; sublinear queries) + - DBSCAN clustering with Haversine metric for grouping; O(N^2) fallback maintained + - Vectorized Haversine distance utilities (array-based) + - Shared per-group precomputation of distances and ring indices reused by maps and tables + - Centralized date/time parsing and formatting + - Efficient risk score heatmap generation with contour overlay + +## Contributing + +1. Follow the existing code structure and naming conventions +2. Add appropriate error handling and logging +3. Update configuration options as needed +4. Test with various data formats and sizes +5. Update documentation for new features +6. Maintain consistency with the fixed interval coloring system + +## License + +This project is proprietary software. All rights reserved. + +## Support + +For technical support or feature requests, please contact the development team with: +- Detailed error messages +- Sample data (if possible) +- System configuration details +- Expected vs actual behavior description diff --git a/README_UTM_Converter.md b/README_UTM_Converter.md new file mode 100644 index 0000000..a56c91a --- /dev/null +++ b/README_UTM_Converter.md @@ -0,0 +1,181 @@ +# UTM ED50 to WGS84 Coordinate Converter + +This script converts UTM (Universal Transverse Mercator) coordinates from ED50 (European Datum 1950) reference system to WGS84 format. It supports 6-degree UTM zones and handles both northern and southern hemispheres. + +## Features + +- Convert single coordinates interactively +- Batch convert coordinates from CSV files +- Support for all UTM zones (1-60) +- Automatic handling of northern/southern hemispheres +- Error handling and validation +- Detailed conversion statistics + +## Installation + +1. Install the required dependencies: +```bash +pip install -r utm_converter_requirements.txt +``` + +Or install manually: +```bash +pip install pyproj pandas +``` + +## Usage + +### Interactive Mode + +Run the script in interactive mode to convert single coordinates: + +```bash +python utm_ed50_to_wgs84_converter.py --interactive +``` + +Example session: +``` +UTM ED50 to WGS84 Coordinate Converter +======================================== +Enter coordinates (type 'quit' to exit) + +Enter easting (meters): 500000 +Enter northing (meters): 4500000 +Enter UTM zone (1-60): 35 +Enter hemisphere (N/S) [default: N]: N + +WGS84 Coordinates: +Latitude: 40.12345678° +Longitude: 32.87654321° +---------------------------------------- +``` + +### Batch Conversion from CSV + +Convert multiple coordinates from a CSV file: + +```bash +python utm_ed50_to_wgs84_converter.py input.csv output.csv +``` + +#### Input CSV Format + +The input CSV should contain the following columns: + +| Column | Description | Required | Default | +|--------|-------------|----------|---------| +| `easting` | UTM easting coordinate in meters | Yes | - | +| `northing` | UTM northing coordinate in meters | Yes | - | +| `zone` | UTM zone number (1-60) | Yes | - | +| `northern` | Hemisphere flag (True/False) | No | True | + +Example input CSV: +```csv +easting,northing,zone,northern,description +500000,4500000,35,True,Sample point 1 +600000,4600000,36,True,Sample point 2 +400000,4400000,34,True,Sample point 3 +``` + +#### Output CSV Format + +The output CSV will contain all original columns plus: +- `wgs84_lat`: WGS84 latitude in decimal degrees +- `wgs84_lon`: WGS84 longitude in decimal degrees + +### Custom Column Names + +If your CSV uses different column names, specify them with command line arguments: + +```bash +python utm_ed50_to_wgs84_converter.py input.csv output.csv \ + --easting-col X \ + --northing-col Y \ + --zone-col ZONE \ + --northern-col HEMISPHERE +``` + +## Technical Details + +### Coordinate Systems + +- **ED50 (European Datum 1950)**: Historical European geodetic datum +- **WGS84**: World Geodetic System 1984, current global standard +- **UTM**: Universal Transverse Mercator projection system + +### Conversion Process + +1. **ED50 UTM → WGS84 UTM**: Transform between datums using pyproj +2. **WGS84 UTM → WGS84 Lat/Lon**: Convert from projected to geographic coordinates + +### UTM Zones + +The script supports all 60 UTM zones: +- Zones 1-60 cover the globe in 6-degree longitude bands +- Zone 1: 180°W to 174°W +- Zone 60: 174°E to 180°E + +### Accuracy + +The conversion accuracy depends on: +- Quality of the original ED50 coordinates +- Geographic location (accuracy varies by region) +- Typically within 1-10 meters for most European locations + +## Examples + +### Example 1: Interactive Conversion + +```bash +python utm_ed50_to_wgs84_converter.py --interactive +``` + +### Example 2: Batch Conversion + +```bash +python utm_ed50_to_wgs84_converter.py sample_utm_ed50_data.csv converted_coordinates.csv +``` + +### Example 3: Custom Column Names + +```bash +python utm_ed50_to_wgs84_converter.py data.csv output.csv \ + --easting-col X_COORD \ + --northing-col Y_COORD \ + --zone-col UTM_ZONE +``` + +## Error Handling + +The script handles various error conditions: + +- **Invalid UTM zones**: Must be between 1-60 +- **Missing columns**: Reports which required columns are missing +- **Invalid coordinates**: Skips invalid rows and reports warnings +- **File not found**: Clear error messages for missing input files + +## Dependencies + +- **pyproj**: Coordinate transformation library +- **pandas**: Data manipulation and CSV handling +- **argparse**: Command line argument parsing + +## License + +This script is provided as-is for educational and practical use. + +## Troubleshooting + +### Common Issues + +1. **"Invalid UTM zone" error**: Ensure zone numbers are between 1-60 +2. **"Missing required columns" error**: Check your CSV column names +3. **Conversion failures**: Verify coordinate values are numeric +4. **Import errors**: Install required dependencies with pip + +### Getting Help + +Run the script with `--help` for command line options: +```bash +python utm_ed50_to_wgs84_converter.py --help +``` \ No newline at end of file diff --git a/batch_generate.py b/batch_generate.py new file mode 100644 index 0000000..41a227d --- /dev/null +++ b/batch_generate.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +""" +Batch generate lightning reports for multiple wind farms. +""" + +import sys +import json +import argparse +import logging +from datetime import datetime +from pathlib import Path + +import pandas as pd +from dotenv import load_dotenv + +from src.api.data_fetcher import APIDataFetcher +from src.data.loader import load_turbine_data, load_lightning_data_from_csv +from src.analysis.risk import calculate_turbine_risks +from src.analysis.grouping import create_turbine_groups +from src.reporting.docx import create_docx_report +from src.reporting.filename_utils import farm_local_date_range_from_config, slugify_ascii_underscore +from src.utils import ( + filter_lightning_data_by_date_range, + format_date_ddmmyyyy, + format_period_display_for_report, + normalize_local_time_to_timezone, +) +from src.config import config as global_config + +load_dotenv() + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler(f'batch_generation_{datetime.now().strftime("%Y-%m-%d")}.log') + ] +) +logger = logging.getLogger(__name__) + + +def load_wind_farms_config(config_path: str) -> dict: + """Load wind farms configuration from JSON file.""" + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + logger.info(f"Loaded configuration from {config_path}") + return config + except FileNotFoundError: + logger.error(f"Configuration file not found: {config_path}") + raise + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in configuration file: {e}") + raise + + +def filter_enabled_farms(wind_farms: list) -> tuple: + """Filter farms by enabled status.""" + enabled = [] + disabled = [] + + for farm in wind_farms: + is_enabled = farm.get('enabled', True) + if is_enabled: + enabled.append(farm) + else: + disabled.append(farm) + + return enabled, disabled + + +def get_location_bounds(farm: dict, turbine_df: pd.DataFrame, api_fetcher: APIDataFetcher) -> dict: + """Get location bounds for API query.""" + location_config = farm['api_params']['location_bounds'] + + if location_config['method'] == 'auto': + max_distance_ring = max(farm['distance_rings']) + padding_km = location_config.get('padding_km', 5) + + bounds = api_fetcher.calculate_location_bounds( + turbine_df, + max_distance_ring, + padding_km + ) + return bounds + else: + return { + 'center_lat': location_config['center_lat'], + 'center_lng': location_config['center_lng'], + 'radius_km': location_config['radius_km'] + } + + +def update_global_config(farm: dict, start_date: str = None, end_date: str = None): + """Update global config with farm-specific settings.""" + global_config.distance_rings = farm.get('distance_rings', global_config.distance_rings) + global_config.ring_colors = farm.get('ring_colors', global_config.ring_colors) + # DOCX title is based on the top-level `name` field for the farm. + global_config.wind_farm_name = farm.get('name', 'Unknown') + global_config.timezone = farm['report_config'].get('timezone', None) + + # Lightning data source configuration (auto-detected from farm config) + lightning_source_type = farm.get('lightning_source_type') + if lightning_source_type: + global_config.lightning_source_type = lightning_source_type + if lightning_source_type == 'csv': + global_config.lightning_csv = farm.get('lightning_csv') + elif lightning_source_type == 'api': + global_config.lightning_json = farm.get('lightning_json') + + # Set date range if provided (for reporting) + if start_date and end_date: + global_config.analysis_start_date = start_date + global_config.analysis_end_date = end_date + + # Update grouping params if specified in farm config + if 'grouping_params' in farm: + global_config.grouping_params = farm['grouping_params'] + + logger.debug(f"Updated global config: distance_rings={global_config.distance_rings}, wind_farm_name={global_config.wind_farm_name}") + + +def convert_api_response_to_dataframe(records: list, data_type: str = 'lightning') -> pd.DataFrame: + """ + Convert API response to DataFrame format expected by existing code. + + Args: + records: List of records from API + data_type: 'lightning' or 'storm' + + Returns: + DataFrame in expected format + """ + if not records: + if data_type == 'lightning': + return pd.DataFrame(columns=['lat', 'lng', 'current', 'p_type', 'local_time']) + else: + return pd.DataFrame() + + df = pd.DataFrame(records) + + if data_type == 'lightning': + if 'local_time' not in df.columns and 'timestamp' in df.columns: + df['local_time'] = pd.to_datetime(df['timestamp']) + elif 'local_time' in df.columns: + df['local_time'] = pd.to_datetime(df['local_time']) + + if 'current_abs' not in df.columns and 'current' in df.columns: + df['current_abs'] = df['current'].abs() + + return df + + +def process_farm(farm: dict, api_fetcher: APIDataFetcher, config: dict) -> dict: + """Process a single farm and generate report.""" + farm_id = farm['farm_id'] + farm_name = farm.get('name', farm_id) + + logger.info(f"Processing farm: {farm_id} ({farm_name})") + + try: + start_time = datetime.now() + + # Update global config with farm-specific settings BEFORE processing + # (dates will be set later after they're determined) + update_global_config(farm) + + turbine_file = farm['coordinates_file'] + turbine_df = load_turbine_data(turbine_file) + logger.info(f"Loaded {len(turbine_df)} turbines") + + location_bounds = get_location_bounds(farm, turbine_df, api_fetcher) + + query_start, query_end = APIDataFetcher.determine_query_date_range(farm, config['api_config']) + start_date_str = query_start.strftime('%Y-%m-%d') + end_date_str = query_end.strftime('%Y-%m-%d') + + source_type = farm.get('lightning_source_type', 'api') + + if source_type == 'csv': + lightning_df = load_lightning_data_from_csv(farm.get('lightning_csv')) + logger.info(f"Loaded {len(lightning_df)} lightning records from CSV for {farm_id}") + else: + logger.info(f"Fetching lightning data from API for period: {start_date_str} to {end_date_str}") + lightning_records = api_fetcher.fetch_lightning_data( + center_lat=location_bounds['center_lat'], + center_lng=location_bounds['center_lng'], + radius_km=location_bounds['radius_km'], + start_date=start_date_str, + end_date=end_date_str + ) + lightning_df = convert_api_response_to_dataframe(lightning_records, 'lightning') + logger.info(f"Converted {len(lightning_df)} lightning records to DataFrame") + + if len(lightning_df) == 0: + logger.warning(f"No lightning data found for {farm_id}") + lightning_df = pd.DataFrame(columns=['lat', 'lng', 'current', 'p_type', 'local_time', 'current_abs']) + + storm_records = api_fetcher.fetch_storm_data( + center_lat=location_bounds['center_lat'], + center_lng=location_bounds['center_lng'], + radius_km=location_bounds['radius_km'], + start_date=start_date_str, + end_date=end_date_str + ) + + date_range_cfg = farm.get('api_params', {}).get('date_range', {}) + start_filter = None + end_filter = None + method = date_range_cfg.get('method') + + if source_type != 'csv': + if method == 'manual': + start_filter = date_range_cfg.get('start_date') + end_filter = date_range_cfg.get('end_date') + else: + query_range_cfg = date_range_cfg.get('query_range', {}) + start_filter = query_range_cfg.get('start_date') + end_filter = query_range_cfg.get('end_date') + + if len(lightning_df) > 0 and (start_filter is not None or end_filter is not None): + lightning_df = filter_lightning_data_by_date_range(lightning_df, start_filter, end_filter) + + farm_tz = farm.get('report_config', {}).get('timezone') + if len(lightning_df) > 0 and farm_tz: + lightning_df = normalize_local_time_to_timezone(lightning_df, 'local_time', farm_tz) + + turbine_df = calculate_turbine_risks(turbine_df, lightning_df) + + group_data = create_turbine_groups(turbine_df) + logger.info(f"Created {group_data['total_groups']} groups") + + # Determine actual dates for report (display strings: DD-MM-YYYY or DD-MM-YYYY HH:MM in local time) + if source_type == 'csv' and len(lightning_df) > 0: + local_times = pd.to_datetime(lightning_df['local_time']) + start_val = local_times.min() + end_val = local_times.max() + actual_start = start_val.strftime('%d-%m-%Y %H:%M') + actual_end = end_val.strftime('%d-%m-%Y %H:%M') + else: + if method == 'manual' and date_range_cfg.get('start_date') is not None and date_range_cfg.get('end_date') is not None: + actual_start, actual_end = format_period_display_for_report(start_filter, end_filter) + if not actual_start or not actual_end: + actual_start = format_date_ddmmyyyy(query_start) + actual_end = format_date_ddmmyyyy(query_end) + elif start_filter is not None and end_filter is not None: + actual_start, actual_end = format_period_display_for_report(start_filter, end_filter) + if not actual_start or not actual_end: + actual_start = format_date_ddmmyyyy(query_start) + actual_end = format_date_ddmmyyyy(query_end) + else: + actual_start = format_date_ddmmyyyy(query_start) + actual_end = format_date_ddmmyyyy(query_end) + + # Update global config with dates for PDF generation + update_global_config(farm, actual_start, actual_end) + + output_dir = Path(farm['report_config']['output_directory']) + output_dir.mkdir(parents=True, exist_ok=True) + + local_range = farm_local_date_range_from_config(farm) + safe_name = slugify_ascii_underscore(farm.get("name", farm_id)) + docx_filename = ( + f"{safe_name}_{local_range.start_date_yyyy_mm_dd}" + f"_{local_range.end_date_yyyy_mm_dd}_report.docx" + ) + docx_path = output_dir / docx_filename + + create_docx_report( + str(docx_path), + turbine_df, + lightning_df, + storm_data_path=None, + storm_data_records=storm_records if storm_records else None, + ) + + processing_time = (datetime.now() - start_time).total_seconds() + + logger.info(f"Successfully generated report for {farm_id} in {processing_time:.1f}s") + + return { + 'farm_id': farm_id, + 'name': farm_name, + 'status': 'success', + 'report_path': str(docx_path), + 'docx_path': str(docx_path), + 'pdf_path': None, + 'location': location_bounds, + 'processing_time_seconds': processing_time, + 'lightning_records': len(lightning_df), + 'storm_records': len(storm_records) + } + + except Exception as e: + processing_time = (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0 + logger.error(f"Failed to process farm {farm_id}: {e}", exc_info=True) + + return { + 'farm_id': farm_id, + 'name': farm_name, + 'status': 'failed', + 'error': str(e), + 'processing_time_seconds': processing_time + } + + +def generate_batch_summary(results: list, total_farms: int, enabled_count: int, + disabled_count: int, start_time: datetime) -> dict: + """Generate batch summary report.""" + successful = [r for r in results if r['status'] == 'success'] + failed = [r for r in results if r['status'] == 'failed'] + skipped = disabled_count + + total_time = (datetime.now() - start_time).total_seconds() + + summary = { + 'batch_date': datetime.now().strftime('%Y-%m-%d'), + 'batch_time': datetime.now().strftime('%H:%M:%S'), + 'total_farms': total_farms, + 'enabled_farms': enabled_count, + 'disabled_farms': disabled_count, + 'processed': len(results), + 'successful': len(successful), + 'failed': len(failed), + 'skipped': skipped, + 'processing_time_seconds': total_time, + 'results': results + } + + return summary + + +def save_batch_summary(summary: dict, output_dir: str): + """Save batch summary to JSON file.""" + output_path = Path(output_dir) / f"batch_summary_{summary['batch_date']}.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(summary, f, indent=2) + + logger.info(f"Batch summary saved to {output_path}") + return output_path + + +def list_farms(config: dict): + """List all farms and their enabled status.""" + print("\nWind Farms Configuration:") + print("=" * 60) + + for i, farm in enumerate(config['wind_farms'], 1): + enabled = farm.get('enabled', True) + status = "✓ Enabled" if enabled else "✗ Disabled" + farm_id = farm['farm_id'] + name = farm.get('name', 'N/A') + + print(f"{i}. {status}: {farm_id} - {name}") + + enabled, disabled = filter_enabled_farms(config['wind_farms']) + print(f"\nTotal: {len(config['wind_farms'])} farms ({len(enabled)} enabled, {len(disabled)} disabled)") + + +def main(): + parser = argparse.ArgumentParser(description='Batch generate lightning reports') + parser.add_argument('--config', required=True, help='Path to wind_farms_config.json') + parser.add_argument('--farm-id', help='Process specific farm only') + parser.add_argument('--force-all', action='store_true', + help='Process all farms, ignoring enabled flag') + parser.add_argument('--force', action='store_true', + help='Process even if disabled') + parser.add_argument('--list-farms', action='store_true', + help='List all farms and their enabled status') + parser.add_argument('--output-dir', help='Override output directory') + + args = parser.parse_args() + + try: + config = load_wind_farms_config(args.config) + + if args.list_farms: + list_farms(config) + return + + api_config = config['api_config'] + api_fetcher = APIDataFetcher( + base_url=api_config['base_url'], + timeout=api_config.get('timeout_seconds', 30), + retry_attempts=api_config.get('retry_attempts', 3) + ) + + wind_farms = config['wind_farms'] + + if args.force_all: + farms_to_process = wind_farms + logger.info(f"Processing all {len(farms_to_process)} farms (--force-all)") + else: + enabled_farms, disabled_farms = filter_enabled_farms(wind_farms) + farms_to_process = enabled_farms + + if disabled_farms: + logger.info(f"Skipping {len(disabled_farms)} disabled farms:") + for farm in disabled_farms: + logger.info(f" - {farm['farm_id']}: {farm.get('name', 'N/A')}") + + if args.farm_id: + farms_to_process = [f for f in farms_to_process if f['farm_id'] == args.farm_id] + if not farms_to_process: + logger.error(f"Farm '{args.farm_id}' not found or not enabled") + return + + if not farms_to_process: + logger.warning("No farms to process") + return + + logger.info(f"Processing {len(farms_to_process)} farm(s)") + start_time = datetime.now() + + results = [] + for i, farm in enumerate(farms_to_process, 1): + logger.info(f"\n[{i}/{len(farms_to_process)}] Processing {farm['farm_id']}...") + result = process_farm(farm, api_fetcher, config) + results.append(result) + + enabled_count, disabled_count = filter_enabled_farms(wind_farms) + summary = generate_batch_summary( + results, + len(wind_farms), + len(enabled_count), + len(disabled_count), + start_time + ) + + output_base = config.get('output_base_directory', 'reports/') + save_batch_summary(summary, output_base) + + print("\n" + "=" * 60) + print("Batch Processing Summary") + print("=" * 60) + print(f"Total farms: {summary['total_farms']}") + print(f"Enabled: {summary['enabled_farms']}") + print(f"Disabled: {summary['disabled_farms']}") + print(f"Processed: {summary['processed']}") + print(f"Successful: {summary['successful']}") + print(f"Failed: {summary['failed']}") + print(f"Total time: {summary['processing_time_seconds']:.1f}s") + print("=" * 60) + + if summary['failed'] > 0: + print("\nFailed farms:") + for result in [r for r in results if r['status'] == 'failed']: + print(f" - {result['farm_id']}: {result.get('error', 'Unknown error')}") + + except KeyboardInterrupt: + logger.info("Batch processing interrupted by user") + sys.exit(1) + except Exception as e: + logger.error(f"Batch processing failed: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == '__main__': + main() + diff --git a/logo/earth_networks.jpg b/logo/earth_networks.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0ee2fd86a68c9680ac6967bd2f3144445d57facb GIT binary patch literal 76496 zcmeFZbyOVRwl3O)5Fi8yBtY=s!Gk*lceg+z!L=cH@FWBa?he7-n+6(9kjABP8VNM+ z?()cQ-@VV-`O+73 zzCFc4!F+^)_5}Sg>cba2o&x|+P*71&(a^AQ@X*mQF##x#P#-^>#=ygTicj_8C5N~= ziGYdICjvquYCc_$FEQ0%Nc}4sPJTD{#H8$>qwvWCItCtIX;}@O_s%W}HMMn9OSJTF zEMvzHB_w4mtb!&OxwxgYv;*@Cx{2j9&0W96=RGJUf9NydPsjePXHOoZJ?KWqz7__e?ag!2pKQ}=6E!WV7HShM3 zu8-lAHHD1c*^RTWj$+c7i=_C)CQZ2~v05*9M%7!f z&e-I`PF;AycQ@4^PURINDI}y283kQijGANyQLeQ}+!><(yFIacfJ4Y% zO(*|py6eQOUo2{PFSzl`CdX;*gcu@<<&NLmtcLQ5@(ul||EH~!leDHW@|)ePR+HS8bL8oFgzROdT3eElSOcx$gGm%`|85eBHS|2# z+s;d91}S6 za?ta2(2JU0=Zofm0yk_LT6`++B+O`WN_`9tCVK9 zK`_Q{N758#=a*(Zv77kgcP*m6qi|hsPdRZCnKU|_>~X+orRY$O2zk2Nx2cI#euIzs z!jyMljYi(e#aznZaQi|@)$GNR+l$T}wv>}mh;R-1XRLYeuOt*V5RwK1Qi& z#Bz-~bALrY=?r8}Iq-BoyK1=xfT~eD0zi2VKp`@y_pAh`=_8&r?~&Y2rUvAB878ke z&AN_Ao4M#JGk)3?vILh?813C^CvY_9D7Ue5v@uC7e(I}eNpq8*$BA*g2PpGtXKqL4 znGdY!|CHr3_@Td=2rsVL73!_|6tN!f%xEF+o)J>6)|=Yw8T28C=W&nQ&=mL=HPs^{ z!!pCsQpGdvLLs~SO!XtyUR%i*j>Mqx{8O9m#CW@H%jOhDwm6rX%L_9**G7=ja{u=& ztrUhX_W~m_BC?NuWd5)ysN_ZyLtqy@skZPT&-{&fVE$m^^D07o%*Wwyd{>%wbcqSN zh{=|O=o-G~j7QDW(Oj|EA*3g${_^9@(CsoRVEY#vLpuEJoowmq0LDO%lmr|z)}9Ee zI{#JPV>YTzO0+&L8L^M!#uB%~iQ8W0pj^%jQyo3aOj9LmOXs6WyF^d=nC{SU4tlTn zd{)i!!$hb4O^32x4MT{4RIKC%RlPWhI%;tV!Y1bJE_c<%yqpelxPigVe{%9z$vpFw(E;=w6u-&I8K_hnW&!>yMAUE zTTI-@Id*zsDceq-%r(ekMx;H7-u(yMEyMER{yhxY z3*`8Mt{}|JJmgMsXr-&9B7A$T)|A$L@QrHab%pN*F%by?#O3TBAjyL@ZG5_pxXv7w z@3xb2g{y$=*y1)H3(*(n+a~5NdHGVzpJ`L2);r&PZw(~Gfk{Nyk3L(!{FS0G;t=Qi zOT&A9xw->?2Xa!K-)+O>OlJ0;3#*42ed|u&j84?GLE(RV0~Vm${pJjrITLA;kZ&SNh;DB%~Mq$v-Up-JLF9aja&+*3V^X z5#-Sqoc>FErPr{E7QywWN*?~cwZW&sr5=3!Qzd^nyI?W3a85qUC2 z8#jL4WHX-a|GTq-yHND4EBZvfC^H*5T|`TUXdyI<>Xdb4`oiO-E~R>l_RRQ~wajxF z)Cv;jY14d&k6{o}ifz=Op%ITvmC5<81menRx2duUp(zTI?~qu&M~7w|VXRXXfo{0( zgpAVM`qqAx&G}8cG019MrJCv}7S4c-D%897NkL7#()LiLCzm0;JZut3iBK21#OEN+ z;^A}oUiw{CUxfU^(A%;?`&49*?YB>dGjVpZ#vEG%Ltk+5G0~!zh{|*}Lj5`a*5_D++}H>`#EBr?31tNyIdm&AC$4ewd>(gkxUYq`Uf z8xB=tP51x{uBZt|adn0(|3F@X(GBh88~(GrV#xUAq$Is9rvrs^EH`E`Q!t(E!XYw1 z>|0pJ-t8e3P*OcuWy>~sz|j)9Z#0B!77>stOO8}~_mRqRvn0DTwC2~&pwN~6l3iWL zC&5dZY;$Rk@=D`D{Y_ZIjul9dC^wl9=oS7bO^r*qO_QQ6&Iq&uFGIpdk?|)lHpdmh zyEJRc$}MN)7|3GB`43(lTbcJJJfFNfzf#%pE;5|tNZ11^WVH@DJza%sT3)m&dInpd zNvs6+Uj~{9CIVRuwCh-5=8~3^v@pAjm^a&*At`bA_kaRo)|Wi?epFZ`i~&ZJq zo{3EH-iTHMt?vOmTSQ0O7gAwSwP^4U_kg<062Avrv0wHFW%&OcWpv3xfHVgN_ka#Z z-Sen(fv9nu}OE>xlERg0W_75y@82o@Vg8zX#Ud4DkzzX(%gB8gX zag;z2&D(~fVPr4_NOfR-5BTNK)Yk0fg{x9=0&~0GZR->PHfHEfRp!ge&b#clw+IqJ zobLe#EfVZjw_alFySQ${ZMgpnHSON|WR>nwg5|*1Zw=|OqBbo`{E0@Rm{0r>ho_&2 z$~q;WH?H+?hZN{_{zowNK5a_A;8L)WIrsFW;fJuabkY5n-~c^O{fQYa@@pm=kp84f zrNP>ipiqTU_)ZEoq_3Y3*ROAxhb2E4$A!+@K(DXnXV+(bFk#;|SvCy#2xW2&MpE7| zK`Ud!6l$g@StF)1_Zd4(y1RvT;mXy6<^j>c9uS@X1ETZxHx{KJ^Y)Q^7}TF*e^4IL z-pwuL0wwXz=}ExILO$WuX_0pfxNmVR;$>W*SslL)mGk?Z=0b*n9CMcyrADYl@Onq_ zk1vXNO45~D9%XC#M=J#>m2N4V@#k+v=!O0BGfOB%=l=k@a~J`9r+`wMj<&%ii&oDi zIoSZwSpE?-&&(z%NBafj5v#DPNWr6xY86@Zr1jE8Ed3qtV6NO!0n()CcW&-KIp_y> zeZKU+;9b-`0FiT@1^o-*{RMje?xiIk!0KO!@-MJz`p*b5VSnMzeN%rCTyx~Q@fY$G27SVpp}TVmFFWA9?+jdXzd1H zI=;@U*W~b%%%(h!d*mYQKz(Ir;*3s;{pn4^DR}$1$dI3B9S`9Uuu1%iW;nmZp--+wkdsD9oYOJOainY9ER6SM5DZ#C*_{jw*&~c%*m}2B@ zL?5gZ@3Zl={>rN$w;))R*DM;nS(wrr2xC#LAGiDq#+_WTC$r5fFsbB$p<`mK=o9Ha zFD%~@N?(oPmFwIE(Y8?wY9)~bO0U!5c#0DL;_mLN`e(UeR&VTb!&sFfO=1m(=v6e9 zezwOVu=rSm61Vv#G$zNz2#X56Yo$;-^gf_EC%%sqoLe;XnT)N*Blf{8_ zPTm3lcz+Xg0%D@j+e$0Zuj|P5uVkNJObuisCeXpK5^sTpNGFId=7_KGz@aYzxa`Nc zaG1J%jSlNkSDHc#TDnt#yz`N@O6IkP&vKW@HE>f8T3U;ERA5u#}VY3oHk4lr@BqC2wruJb{#Lh3j zU(-p`3e0C}>%h=7L@nNX))TO+ovSubYxJo|mZSwwth|mM6`_`fxRmI5>4OSlk&(hn zy-=v4QoaML?*tW-UKW!On^)4w39N=KR3K=q>bv!^Jp}gSeFdwa~SkG3; z={*4T>-Ab677_12k+|Vx zz@ZgmR~AJhUm-7>%Yv(*U*Zi!^;~8f7p6@palP``QD{&2#y%YvQ;s0_# zWYeFx@bbcMw6xE^z5j@OLtA7=tiwA^@>0<{Yj2B9SYqWXo4hE!5!!~Rh(wxih+jra z_Dw!m@hB_`D^_!ZJ*4%Z65-fgLWjkWO|GPxgsZERJGayv!;UNlpVvr@A)hWAl z29rOZsmf?|!x&|5A)IQ~U^!``FbHQ54!|e2oVAJ*BJVb}c;(x))6n3YtRGU)?$M^_ zg4?G%)9uHV&d@}4a~l9>H2*52h&4|Z(;yIKQ~j8N@XnCWkTxQ=dLSly_ap1a*LY@# zyVMd7k5Vw`<7RymS*}!VsLSTDh+uO-{O073kw)I)=CoJGt3$tdUc5}tqM_!@Bcp9t zf_bWHEdH!2)e4j^cQWm%2+ojGA9u6_>&ci1a%}I#h~TR5w7yWVD73FIm1N+!IZZAv z8!XbB5R!fXaoC(40?(9!p6QH{kUrIE_SQ~gFP+3rGi!Qqlq9TD2trw7BAVLBUn<&> zF;)-`>#`3jPOP3DY1#d&NXQ6beRDV=-NFGp(reCEI3t;Se0U@>j~gn++uJiOvsKNU zAqWmZ1|_4XAPFFznW!JOlsh4sK5LM5(n^nB=w@RA8?$+Qzmv4D4iGFPwAfWn07jYV zmk#9!O3@dSk?ToB1bjwy8vvtB>o76?6xmh(FrwU*k&AsYQ9U%IeqChK8b!36`=-aj*D&*RJ6!4*brzJW||7={bD@X5_%?R7c}V<*BzQ_ z6;=N;&BzPBQ&hc8_aoFMCL*4%HIoRfZ6;Oprfc&U3#RIoBFKtIjE?6*lQQ{E1WPwu zwMVVr%u3NZZEWbEd9W?R=ekzA4(z~Q3_(kr)45zDhBVu510BfRV%@3C>%t-=3lAn1 zLEU}E!_wp48+qWE;&gYj@P^=7khGP-#`9e&m9nbHfg(aYbMZ8S@$=L;d>|VKRj`KN zQ^^5-q@8LxlR8G}nKA_8WmZeBJl8VsrQgFxS+qBWjGeQg-Lx4>EKq*N{(31jUU_;= z(|Mhi1pAFA!RbmKKJq+)Sp_RrOwS*7Aqy%j+iy1VsaVsrD71(~QVJ?krn>~fWHOj5 zuvL3TWRvZ9Ry^c!4jeo#co)ak3muEP=NLd(K8Vy2E|UwlWSmM1t8aq2+#)o9###^R zvEBuBFmD#y9*vb!@snQ~2+bHONORr>pF0LUsa@XTPbvRdZm_oAs1(uQZaA4 zFl!U(FW_k&YCy@_Qv=R-U$QSf!Kk*6wH42{4cWI#v!@gk`aVKFt1;!-qEhvN)3uT_ zwx4c=eDGw_K8?u^(-Du7Yqx6WtB5|P<+l>T*S$3f#l_5QGwu=|;>VikbCbgk_FBdK z851haAF7PKn=6wqnLmD<+iXxS3Lm3Dt8<{CJE4;Yu`i?*+0S_?2Df&zez%{;dHuwu zSn_c}d)l-(&(J;K8PDv&gxwol&*jsXg))lCZ^@^4V-gqGlinTyom15S7-$-w0T=;= zB2xZxgy(+pLf>yZk6s~k}87WA>~6hX(tB? zQao!FMiK~=(kR9;XO){<%C>0vCh%p*8^{?Tnt98qn$NliH&c`S)SueJ<-gZouPMrt zc=xKGdlI2+hi|y0vX(+L5uj=9S(B`xlJ!e`ajY+A*c@200e0Bz8EyI7WN znAEnjMcE>BKT>Uv=9Bd?RjWX?c9GYV{OvbUF#Z`&D|LK>qdq6n<>mM`z6n}H`tSUUqmu+UdubOD4cdx=69e1+x zjK8ORO8M{~xb|i@;LJr;4Hf#D*pV50K1JZ97l21*fp3}1bylMCYk4<+)?!GPGar1{s?8=`Fko>N5RG=IL(^UT=@ew{~_1a93#ixNM*GaE|!-#o*~ zZ}4ZL`k93`Om>6%8l%;wEHyWLBQefar^-^pXP9t|jOfiU`yx9*K_yW^iW4hD6S+z& zJSWXToI|4tCj;fh6r0N`xB4cpr{&HO>ZVR}P_Jw7oa?YuXg}hpFb{1s%C%q!h zCv}!D`8ARJd~8l(UO`V4Q@??b(LLKkPM=`x>#%(B?$akGP^H-@Wz+0JOfFnZ*lOoM z?RknkbF-!K&) zsqr?CbLa(ujAgv1r@(Fj|CL()i(vehm)QAMuX>E_J4nR?P5HqA8dEh~4T--->~S~y zb_&%zp$-j+-4wQVHrA=GXcZqfI0*y%$R_PptACfJ%`7Z#o|V_NpdngFS$MGIPTF2{ zad9m!`t|M+yKz;J z6zRey#U+KqdE>Ctrc0lO!j;Kx9N%1WVUweQj^ z=Tw*yM;BrP#(E&iYZUK7<@Oo%JzSECW;tCKxaqLDi|K#GJ$4B6&Sot>Kv6@`A4!^~KW(4C!;Su%A@}Bv4F-Hnws4o{CI;4(3xeikX+!BiL z?R{?9_s;2lk)wHRv`HV-%yGp+%+)a_>uGMr@|C6M*a&THIQ6ewGz5r%j5Ky{I9TW< zFuciWKOErK$2pPiCM-qH*(+aLRu>3nmL%4F?If&&ZcaRKjBrYr}cp9TFf0DuQ!!DvA=)etMsk5^>CU=GqJ)9_>R)~gWynFOB za>IgNrm%UzPh$RCL9|rvm#6yIgZWRL2r^wWMvJ{J$Ac|Xt?)Tla$p8kH<6>PG0Z*4 zS-+ zmw2qi3LGGx-(zE>FwECSY^&pKhuKA<+07G156!TnvpkaH=(Z578*fwX@qmDU&g&le)ZdlR^=rB+A2qx&Z{G4vFDp)sy4 zE^*ZNfINy4FaI8-=|0{~Ovkm!Nu$E(#aZEvv68LAZ;oDX#l7giJUkVFGEDA|d#^$L z%h{vT?XpAP{Rh7Ok%4QJ@tW}V>wkAKrW0JmKl!-qGwjb*;(xVD2a`L#I*Af2cKm-> z;uxX>$JR@6F8v0%#E6QXy>A{pN|`mv)M#>zclQx>|f8hkP~zHf4MXMYdiI{kFOdXpAlqN9iZyRuVenQoIK^ z69(8ZzC)e0^#za+0u_B!b<6v*_{mnXHZeWo_A5^(#DiPzvMIz)a_2n?JFaZDJaPk0 z{AGOngFB)qo~U}yO9=Zv^l35R%GTE7=f+)ll9nk#w z{qxXZziu1_f4B;L2nCX3an#@ErllkgjC)$4(+i!5VC)qhfUt`=usA@*RVD|unte>#@ece6Ev?EyJxC*(BGfa6!*4GC`n`q zbsP<3@eO?3#^%WIL~D_VOVBaut1b;z)%|`mR%?hD1fL2g$E-_4&rQBp*d?V|pMZrX z)sF}h=rD(?3$J_^cK)7nuD&wwRZ@WFT>+6%pr3Q5b=Lvohs4XNr^-vo!svRAe|Zyi z=60?{zTa5RduAyyy`$L6*VJPt-roqdX7?-4MaaP54JAMto zRv3PF=kFZ_MICd)73@Cgcsx)?!%-!z!|-`|Y9>hW!%}XDYx1gjnjPa~6@EA&BuB~> z0aBsq6PmM+m&qafY#{lLHuixpBmWvV|D&8W8-4pD`K_l-Y!iMP!|%_pBX_S#x;U#d zN;X$WY*;vt48tdXY~_&%BU$y8pX6Qke>!@r(3`)&WvXlzV*sj0wkpgenTOfQ27rfOywtG=pkzemm%UE1bY16EAz9^2G|4mS!Ssi&li@%N+6ft9omMQ|?%vv{FYa zY2V^wL+MU@OcM*z0686ujO!_TE7!=Fh<(&I_-(v0X+#RY-9|opJ&pd6$XG3~&5+3Z zgy_|A1Mg+}D}=%RGt|n;@A17vXfWX$8XC#`S+z}k>7RR~R9x&1F7sf|d5R+>tI*ZBDSahY2e0AHDr4XNuquj}0qQZ!_s2NK$5WhU={49<^%rzTM zb3;`_IUA)Z7H1}F=nMUE(ziufb2TR<&6CDVXO}oNLRM3Xb<>g zLyLIxxZHu6hg_Mg3?#)C^I5K)0xoYug6&x9Ah1ezhAJ0FHj*Y(@FpFLTH1lc|o zdDmqkSaTHei^q_QKqSQ6Zl8_S&q5r;tD&~&&K(|82VLjVmsf1pZk!pKCt9=J`jUGJ znPe>gb&mREUxKrcB4#BgM@Or}{grU=pdBceJ!{s-8KrzR{~4F*O~9&h40#>~m#EyW zQJbR}lalLck3k$2dj` zivhsnWyF6S>dUtA%UL0jEY|lgna=sZ&M;;_Mlw`At`9SSb{Q@w9v}~S$+gS5ciqc{9I>V&HuU*WLyV2KROVZ zsMn&r+d=5=0{w5AU-&y+z|L&$0g-}G=x|!OgGgKU#p0Zf^5^ond7zv4?|VRYnjJgp z?|wzcMw+vWbxUK9J<1<f#>5a zN0~RVSr7X(9Po!DJxR&K@ljs>x(ASZy9a^eU>_NBwgaL@QGUc{h9|bAm-6gY+BiWQ z>mU57Apxtshozo@!EmUat}*sEM%b!yRq8RGsjLOK7-sA{ISaznNX(&4iB}bDa8Ec1 zmgX49k~qVQ8Mi&F*OSeVbt>lx3L}3DU-oQ#yE53dBR53vnQ;Eg2prp8cNlj3NnFF`SAr! z6d;%~h}ZX+mKKs-B{~>%d2VH<7{Bc&#%`m}wmoV-qi^e`)T3YNqQ4&F1X@ltrb=X` zR&oo*ZDi(iAr7SsaAJMWN{2TXPbv^>qGwxOGM)L#g*TPUQlvu-r#TOLJz4kTkk)`j zz{wVh=nz*qTO)LhBytg7#{ImI7VAOEYO+&r*%Yzm1m*WKE8J-4bxwz0sTh{(7e>Fl zW||XB9U56(CHazWNGzD&d=koD!G$ZbK7%$M#GD-$OPd;Fw+H8KD-hyFbSHz_nD+?&k3~&fsV|BjqKGp}h_(Lk%?^U&ZAU3sMwl!2Q;jYu#t)kk(A7 zG?5%+JFs4v;2yx6lxzvPvop!EB}YDc!PGoZL!Z;-{7W`0)5@4Q*|iQEy8ZIB>b+AZ zJJg4$DkzEqM*h90XW8cacdQW8Iiq(~F~6;G)GhfIxMP|VYCGPJO8q*Iz6wPbnQE|Z zISPQOVS3_@5IEFIxTu*Q?pLMZLKPbYEi<7LpwLELNX|8^-Lo-%8il zKja+PyOf%hsw7qa1=SJ$K!7o8LaQrmZw>Hv#}QI=z2WsS`DueLi7-rwSFh zOVr@jE1cO|af0HeTq<~sf!FvI>E zXIZRK^)~hnrQo{K@#o97wwXiUHd%QfrM2JGx2X2 zSDRw#28DwBQ5Z|fA{C@&aD9c))1X)8bmgW*IeZ{Us{@3|2eJV2@5UW9rvl|0Il6?c z6)=XO@>KSP|2{AEz!?L=skb^>RYdg1-nNdKjgw)$5`1$#ok2|w5Tt&aTTSJmo?n=91F`+F)j!T@CN{;nD(D6@_(Dq z9M9^^b3*pV7}VZg*-=*v@-at`T4#`?`6?@`hzUwKvx@t1P|543#)N37!t$9cRZew(-1hQoF=uA$@e8=ILkHtQwQ6tm!6UXR06*YOVhfYP0D za~Fg!xU``d?`_{4qrCc$@!t2sql`?z4c5tF_0&22M80REop%?pzixW$e`DM-a&jZS zv^20o8>rQLy`mKVRj=k3rEmGvb?ibu!_tZLtVh({C=cbi z;)Xc#;P708mN#PtZ+6+Kg>uevpWW_RnHxJ}-ssK~*Pv(xE5i0!ziqAZJV0|; zk!<3TIBk+!>JG#*#eubTO7P(IL3IVqT;eI?31QAIe@oWb3-x9Z(ZSbu{taAvie3r6 zPLPbPyXn-3gL}YE!19G}=1;RKY`NK+58hjTl;e?vw`{rVdJ7|JH;#uwVzTuc+MRz! zXKY|tNR&HPlp1yJ!yPcXdwPxfM+y6G7#M!=yK!7u_I8LncnVf_EBq;p{NW)#jN1Kq zg9qyPG>BN=Qnj`QL4h}GyHk({{(xeT&G#*bv3krHle0Yvfzl?g37a~{{z~AMZ4>uc zc>2g-25`TYVDl}`P;v2Ttbs{D3Yya*Z*)>q{ztzFp2;wmG>1|0cBGy4mYa(*2g^K! z$u9a-nVTv*%`j)LmQERT|BcHRUj>{WH@hRWFK)9TkSu@XnNJURgrfEOUjml@ zI}yw*a5D|bcnf?NrgF@51nG1scY@E!GL-Tye%Ik9CbUu3bVMIAZ5T+tb9)5bxwPH` zAN$^2t5BII5G0AN>*kpxZ5mj0HeTi0>gHV>id{~?z&fk{QB;)h+C*1dRb6VDi9WCZ zy#c~V`HEHqLdzW%Gv&tj`RB9IA&byh!X5y-wOf!Hs07Z?IKD}AhZW&B4UM}gxz1|p z@y<|*G5*~!wHsC}x;Uj<+ur8maPT-7lAN@DnspWGzxPl!lhC?PA~u+PN9qNI2A9YC z@HEw`k5Tf&A3`xN;ky(u`6N+p5qe!ePw}7Fq|FI@x1bPLfzOI*I{6E>_O&$bR!yW6 z<)-FM2{1woO(jx&OE!*gImeX~9Q=k=P7776t=6Gf#ZJWiuIGFu+TElHi9$9Mp5oO3 zba-RlfolXs78ggP^l6Z6u%&@6sp2kwT55RX*=?n9yFcK2p^a7EJzy=>x_MMy(+k2zstj{{e8(PS?Z*^F1BNc#Y}&c1}(JdT{Y{1 zFd`8lsJn_FoSHAUX1(@VInsSBp~0T=#DO=S}8IqG^*+{SKg~;^0iRV2dk7!LXK*nYniSr?Ec!&S>@ernAntAY3I z5m0uS1$XTM+p&`*{}Q$^o_!#uGh4R|_bh}$0qSd!O zE3(j2dR4M7M$M`mQCqfrGDt_8-+0UxSOP6joBd>*nwKVymrvarIV!V>WqStU4CHYZ5#~`Yx9#r`p5)G!Cd%F!%>>CZ>+)(vrz^+|5QY= z`^?Fnuj3#YA3;CaM2qB6`K4UBDHMn|P?Xy*UrxSO$%&!G5C61(unQ*#s&J0w>88=W zu->R9Gjc>L^QNJ7FjrosK)W(}zwiXgOa4>h)bM?9B%A*4kjl zc;q?BKU8rz!Gg)0U=)vy8w4F7a4Xd!OQ$JcHg9Vbda zt###1X4C)=a4lHH1_x3lt$38F&fNv6IT$<4PSoGTr!awx#tiBo$6oJxTK;bFS7#!_ zVyBf!VAXbKldiUo&f{0Pk z%;;;g)z|_xng$E{@;5X|B_h~r9I~0Xi^BIIP91nZIaugch^i}Hb$`=2SW?bpB;Dq- zhoQ=cz$YP>2Kr=PiWmd9cH|B}SNUX#el(&SgUBYxtbpKL<O8{$cuP>E9Cu$v6-|=L_tJbIAVok< z7`o>X+P9A3+nojKiT_L(c#_xAeb8`}o1Z#)fpZxqdJ7`x?aeZPFiy z13t5xo*_QAR#=HYW)2B!={+(aDr!9OU*_a-gUc4(5sg>czUnUf<&LIPZWaQBQ}MY`-wA1ME3XYE;x`K#^0{7ITxdS#l}%<6(FTF~>J%a!o{-=`SzOR=do zuTuhJJCAxvT&u~veiFVud`#Y#%BaU}pU8*Kv5R9vj(#h%%uf&vpimtB|4Ar?wqr)^ zywc!Oe5>#(-CoA~KPPlHb^6nOyqorA(*XO46yF1g#z3`9G z0d`qY(e^)C>3;}Hq}*_%&%5xbkq=?7QDH!YuDZ6KA!5L^wIH+AqCEYQ?S*=_t}e{k zV8)qT7Y^yP-sL|oRX+$C$*27C%BlWEgA;SW588vN!IH}Bx;CiBkcUtXsd_=}(J2Yh zm$t0cgWP7ze$R#C@_>pk4)B3OnqDA$LdC4l)%}xNwKHJPYeSvO; z;L;oMlofMqMg^Rg-|({6k$qf%V@{pa;c{iO!2n;=M}Tt8pnFgz#a6*otj)K3#sTRG z44>m(CwI$Gm{7_bCbO~SDWM?6`KdLn>_MBW>W9N@tWNAF2cMk>bwOe!dcMO&V}*b> zFkfK^EJxEW1{nWy?7}nBtG9L~-{;PjX5Vr~lIxQFO`{ux$TnZSAwP{phl8N*ve{iD z>xUWTs5g&vFpm8MwWyX8d`#8%0kIKQZ8xd(+KcVa8RZmh_0$q(*z@#~S9%-%fFdSg zidlNc2Uh@q&#m^~U>6&`fwQHhl#g#^7*g!B_VBF0?D?9*mT##C0ndWjw>|ul0E23O z`d_KxY0n~wwbx_R+8*;SS-GB1S}M~qTC#$9wPcnF^YwX#qbNipJx~3Yso7_j9#GQo zmya2J#>y-d_VT(< z6HaMM3M$`Zbuv>GZL`|P#m>#!7d9&XtQUu;`D#9IZXH;ogj7a6kRZg^B{AvuLY!4L zn*filGIncl(ogYjUjyeaY4@0qWRq}^Ua~4)A$QTNdpEHj1eY}ZK_!3Zd)EFJ^F2TG zL|s0VcKOH#fEnP4qPLLWYqd5asHzTej#`O27d0lb@-w!U8J!LmnUV!KMd1n5-VSWi zcZ6~KtbCyfTBDHguaZSEfaMBb1Il1B%7cq%se;8twx%Utx@x3;FBR7Yx0(f?8L^QG zVxP}^($a)!Vl71*R@#t%*njN${N3vVZCcmtE#^$u-uVi#lIl@AE@k#iGmNQu5s7pQ zPr_#_NTotV(3V_G{8BBpNBmPK?3Q(>d@Vub+ucI^a{nq97KJW1Iq8zEGq2qrFEwpMf=r3*n5HBUs?__h31J{f1n8kYl-2M`jKmRMtU@~ z1zG8L8ZQXBr=rK=)Hh$Yn>8DWibe`t`+doh`>OrsZ^6ZXC7ot${OcMmz~aVJ_Kt5_ zu8w~yZ0$A*Y`PQm&-DafNqql#iH1v_b2#BYBzwleu0+#yvPidR*aJXSfXMx&^E=q@ zU&uMZEne$=xY}alNK>x)hk`quKLvM-n}3zt;r^rCu4{qjk|y}9W79by_i$JBqBVIu zp&cOccMj6uNI@D?zstg!J%U3cF_PwBh-+8Y*^bf}>?^$XX3C)c1o6tVek zDSyBH;?(x5OnP$kA`JDi;U3W6UGO%FSof@LS7G)o07F~lA5-9e?T3I)V)Vy|dBo>?^j^Y&64Ur=4|{;*8fIwSgb0lKROH zJ+FJUnw>w0OIOC`V8v}vS5F*5X3t(o-d}qBqyhZs_ml;CSox2g*3$xD>lmNhtYR2b zz~tQ8__k#m#!fM}qdE%{11T0aS;6K4zCo1C7SqAz&@kC|p7 zdFC|_spn^_`qQ%;;rLRuhnXv4MaEOdmRYsfpnHH5^?*qeor#Z*_q1{PXnEaO%gN5~ zLS;*S?54p~d|`^}!_@spw`g()*Q$mzeofGntvfhL*uz^Sswr|0a9vN~e6TBrEvsP4 zSxI%j#WR|5zeoIj|Ez&(Px318)}Pnr`t9W6IljPIy?HBP)_K}JpuJ&#z(utg(eaQB zvU=ZUb0l{>FX9?;wYO!JyG>QpIy@fbi1zSem`te5t%n=e=%*y0 zp+J#Bafc#-;tr*_1d2nj;sgjDJf$rz!KJvnyF=076cXIsDNfP8^!J=|o^$^4etFkB zYu0?2tTlUP&%Sr|ecjh}jS4)ifMO9_(*CyB`(s<##*mmf$?V;V#9Y7`<~=_Nb#7GB zc0eamGR^L1al7#caE5B*7#X~8;n)08iV3VPmUiUtP46}pq=tRYBnjEOl|BFMNU^<} zZ8zzvCPH>A$wM@K6}f-7CWG_8T=Rc6I5Qvh8|=M6dF!p~qQCfka|62Q{j^HC!xjzO zZS#mVm>0Y`?VQIhT@j?sZJf8m6dT?n$?Y=U^ze@VbjBB78JhG}0{i@`mPbVo+?w50 z@a-l}uOhKFL+Utc(I}ozP*T#Oo!Du`Vn^(EAlqJEWlF;k-8^=P)xlEDAAq=4o`*`l zc3h4^;TS2obn74|DJ#tthMbz@=z5~7pT@Y>)RZRDy#Ht3U`!XiaA1T6bOLSNQ|%w5h^R7uqwXi9HdrwE>90N6>%)9adYI9x$kV z;uBGmpWjeOiqYp5PjNl}M8Qkj_!AcA&N=#dc+kWKxIum5+fo_l)%Un01jt+;HdwC! z?O9OlZ)O(NuqN&yu;rk^S58!)wI+=dlgSJBwMr{y2bKK+cu=4cezUEEUD2RjRW6PO@KWDSDinYJ}+|zcn|3O&9s&fLv>(=-W0{&AQ#A{J5!V zD>KJE2N@m=3Wt2TW9dDYa-bk|Zh*$tb<>Wz7CMt}O$kx1br=_1p;fk@%{89qO_Bg* zQ?{@tXJ8IvFM;&XYO@~_rQ6C=Ou7*uY&Y5VyH zKy$Gq`uOK;htxnG2#>J4{eKpBHJ;whO;JOYSzGi(%f;f#CUe}4zG0%I4YmrE`eNO|coy2jy=A3hxqpzTlM0`YH&J@ErLDiK#5KYB3H`ev~ zUE2(w(!qlKJR0%Zs?hpvIGvR^_R@n$%Iv^9js*d7g2?_(i=K1YF@AwCY{gYlmr()q2$CI>5hy`BNfj<#d4xfIPsmrx(XT3V3sc1Q7 z#kTg{F~PZ>Yqh04jn24GsfqLxu<%THE|pdXkZzRd6VWx1j9>4|+Zp4L_PjZGLg+a( zn|D@&IHW;}=9Oel5>IjCN{n4p7>qo5|7Ve73j3)?>rA(%K=ZkaoT84*}N zxJNzNpc@u>voyE*aR{ky9kEBWmgd@jq2^ShQd{YR)xAQ5gD8&pnr~BrV{R-~dANb+ z);K#H%}+b8tfnD(lJ8)@Z{h=LRg)DKgpJynjQwvgh$yV+3L;TLqEgT(5=)l7a#fUf z8J4`JB<`G$7%V(VQPH!ng^QwPSj)wV-ZAQ=r4y7@iHx*&c-1V!tt!-RTG2cUPdr>b zeIm!AkmV_T!Toi{`9)gE|4F*)r#aZfLXwa^i$)DujHh&Nml|ex@D*@hg$JUHH5wE= z!jpQtY_*%ZtsHk|J>GsFX2Ur8;g3($2S5snVM5pdQO3cc1+H%aLl zRoAKF?e5?sCw*(#`s>Nkql7j(pfoVxVom%ArrkiT%_kCmdqoD$JYd$< zycpQ`xTPF92wZZ0w*^miZtAP?);z1yC@&Eo2g>(Y+Y2T_IWAY7Ui4JRx{C3!W)D>N zGdT<>%r*u6-1Z7qrYsFnpdhGCav5@=FyIak`DsArbx$^ZP}<$pYy6sGvbi;Oc!anD z>q&dgz9kh|i922Dv6A4#i;R=F-+2ffH9T{saTAgeEDPp4J5y5gk{UoQVc!P!P=J z)xASXM37VZP*7*2`G>hrJRenzt7~54tSuWwh^*m}rZSe}e*%>*!wK66zR3p*m;^PX zI!@O;Jol8V5VXbA(f^-IVyt)0%G)X?V{PxNnpwEef(Y4heZ7I*%DHnCA;N?ds{n+h z8_WIXj?)!4eRfL5x2xbdS9fZ1qBo7raV&8wRNCxm!|~>X*Ny1c^-y%DRqmynr z1S>3Xkd*Wd2od<$$NG!x#6T@6L4=ApK?%?k?8N=+ag+@-XAib}k4jk+-Ya}*059J* z(6VoQ3a$?Ozb?W}P*=03kI)p^jq3rnV()A006$<4iaCn_=$Eb_?pOsTN!97;}WQ{txrp9b8s zJzXR4tUv#I&HgATG9mjm;06$!GyH!|1Edj+87}59Eq7AXP#s_T)mg5aCa`$o%pD_p zwJ`-BsDHraHSbJPRKzN_X%#ksA$N~nzD=V@U2$Q{k4pC78?w+=v#oiXk_+9Wn6aHX zRfA5IwD{c;A|PiNSlGwtM9J&eaVeT42zA`Egixo=24*z}Qa*g#c->HCk+tT+U5;Y; zu%hQ3ko0)rpVFgTob_)8j7^?7P+@LhOfZ}`{#-`KzS7bhpff9WcahO=-Wy>fteCe= zV(SF_)wI2rD>?rEsciJp-s{RsB$;TD7$EhHP2jh3TF4UdKkD^=Y1qZTv0;9Vc&yF2 zR{xNV|40l+rl@o3^8&_u+)rcD(s~XfRbQjn{s-ju^r>sXB5Hgrn2@{-UQV8hUkhAR zd%q&h0`tyj*qca{*&KK0vKn|tWLg}=OgA|_ZqA`<#dB$x1j>rRe@lP%s8Xa6CmAuc zn~N^lQytQ*U&4jq;0Iof&$;rA)tP#YhCHt!iBjErH?Rvij`Gb>?&`s-)6Demf85kq zv+r|a==^IQBdDuHh&9e7Wq8nD^VMWb%myrvy()QS@Wawr_CQ5drd7)0_*cq2#(Ng( zHljNkT3X5_NaY#P)AQMS{*l%}5y5diEAx+YFg*QeKb>I^g~Jmj9an+|x189fXA*vM zeCmtdzL5#=r%y#^Z02prKBntun5_eRROp|@q7>3=zNq% zsdkY?u%6Zk%HZ0L*EPl62jI;xO9KNz4Oab_(MP}z@5+y*<7aztl z4>_)7MtYZ=ulShLZQ1z9KOmp7#`l?Mx32w0v@E1c`^R@_apud3Zy8V!Quk(U&Dp}W!EGBpbv%C z$CT9JW%I}4lBoZ!!>ABPl7JXYGeu>4|N4Nai;w5oD9o^bydAYHc6;$P^anbGtn;Bb zcW#!@H!Do2`S%WE*O;=mL(Zg?*>G8v+MpW8DH-R)gWLsn*>8r{C~Krv>VA?d8n&Rs zrX>T*xV*^)>*Q$hul_RArfXm$a({d?<4Km;jII(-B8Z3><%hU7s8zoH_EP=FCZNAQ z1(&q=+%pTi=|UjOep7oJmW}+hZh5J){QkTdw~Zyxx;fEjZMj8uF++3<69(txU5wM7 ziVZW#(;nRlb6JUzhwEU(T?%`U^~rR2ue-jfa$8@^I-q8x=|kDXOgdQ2F8{-K7-peU7S#$I&9d%nR0sM(yR%{#f4M0e(Edgx6N2jt&Rqy()SAw5LVJ7}S-sC#LcE>jB zh5-)>bhiGBO01*8L2KETU)F7>4_YDtS!&-0sSwbXH-GoU|Y7b`1kma@mz1H#O*998y#zoz*!1ovWWE_GB25FW@W&22EFbV5@V52|k+^9}p ziJuy7>3zXWGbFpYCw)HquMP!pjD%A@t()9i!ZrOn^O{7_(wwCU!^>o8dS4PBAv?TY ze*2F!?4Bs`k~q>sd5rVl6`wEfO7G1oe17r>sdHaTonEt!1Jwo~_E517rd-2y0Zj>> z!mGx_UT9KS>-9l?kWxK-f~MOtIht2Dv@3XE%n*`uJQ2^&lin~)U1CW@7ySu95~cJ7 zYu#RtULj~UHZo$^(n=Xn}-+tbD8pq zSm0+JO`b#9FqAai|_?#JUu)NC-vp;C7#yZbMLy6;`%nFl-3sObHL%K zqp*i#U%gf}iZI7j4%cQ`RwsWTN{&8$!ra_I7#D}5kND6)l2&ti> zzRUF-KP?T6nW6y^;HBMNC8w!~AJU2hHaan(B%xjypUJp0`Flh;k-tNhtb7#wh^ zj%35g$mr#o$#a)U75dd(hl_^q zm$fMCPHk^*JU=?4s1cqZdTg-`mzRvq-ML~g;s9;+otNJ#w$(3S z{SI#vbU7s$vPq(<7mSib6ax3I8B91Qdo7$8*J{cuIPRw(x8lAs3a+Ots~jc+3euit+cHT$eL%HV=YUs~&DvYM&7cY9mPDg*UDYKN2qFgPIIAmV=Qad3EI==e*mw=-29}`MyD}&KWn3?U&SRS0Gz@sxa*Rg3{!^rmXwO*d^AQh z7BMX1*S&dH?S;ATeKQ}Wvq`Kb`A#sGv~HP~StI84t{Y2eQN>Lk^H7?kW=2*$KpEa; zjZwnwXpji^3W0O$HdC!|Mq_r#TkfIp$jF#`$W;h4eiFuubosPL<(JNv#orPrkd}EuPLh*hDoFMeMO=k!ucB_X&VL8gCNUl=Hj5R77p*onx2mRWhJAKhxYu#G{0iBdA4O*g z9n=U$!fnpey|4WWC|io?DE{eoloe$;Q1%bNlSa{CgD4x~!5yN8*%@B+IrCaf>5(({dnMp7v|-yHKx{lG%`ZkxXd-{*2ysAQ_=%V#xK;~21FbccyjqmIJcJ4SN`AuJ31$vaVEM8#c zB+KkGFBCC3rt=~rLvtzGy=&dFO;#NSi9{W$=vh-PJWa+!*ab4@dA z7<}n*Am+BbFsWursA`3BFj#4Bao)^q-uKF`VPlMp>15|?;M_!xZXe!xH|?GU?HOcY z%{ zB~jq}SrlQ9w!1IOjCy0^AeYZVZ`kSp-(t94b*`u82@T{tD6>wKWd#UIo0b2pojs77 z7{IGdB8W4-vWi2#PJHwnNHt~NlDNBD(%rpn2@kiM$EA1Gaa0Ck@;Z_D=DiY_GnhQ=9$FgHG2Obbiu@Ls+`boTl8 z)PD>X{y%`r#ugEJql;95%T+helkp=j?nUUlJlB84|Gx$z^*;j@%A0Zh@IDGE|JB;> zqik@Z-pSU)t<)*&|Euleo99>=P@C8&|vv0FgQu>NiPm6ssyCZ`eQjYIB zHvAHHI_WvQ*er5wI7GoqY;%Yg|2_}$3JiVU`tS#g?C!4kEUQ zPPI_Vq)DcKJ1>3t0Mv7~G;QPiLblX6T(E1Zsu?}l$^0!*FH9;ENUQ-G_x;WcLrZ>2 zjL1byD8^WF{vxJ`xWch%6L(#2U9*&4&9|)t31}jAu#I8WI>1&Yp^bA9c2e6?nIW(o zZtf*vq#0{6VqG|7{xm6w>x0`Ys=F$d-}!fpn^{KQAO-TBqi;FqrOaEfor86~LrZD7 z*=Hb@x^sAuaCU-%g1*btK?5rx+S#5Qv!!)c>wYdpSz%a=+h;5KvcCmyo_v%?|DM_%Ghln@NjoWJYJn_Z57TV9%;DkeOrCX`PqrO%{9G4kK@oAOx>dgP0UKSU(>bkFHLG9A6|DGocD1VuX{Kq^h_6Zz$cP{xo zyb5#d{(rEBFc;ophgLhg@-Dm^!I}SOA`36LzYYGYdMm!VGs9720L9ndZeB(2-$BYY zHd~i>W!W*mSe=yHNE`2ncTk6(7tXeenP&@MZ%tq_1vj7$V~=}tyWFYPf^F_(Au6OX zqy;ojufOpqy2?VISK;tirA?|@90BnYH4rEQ{e5(tl&S!Vi(a*>hZpU()tt;5DngOQ zvEz%}jzhf2#7RYZU~L|KookL1&D~J_+Ye+8eVT3#W(4_WwQ~-q(*v1z7^SnBv zbtayGaUr1btSEWvihE8EqmduD!J$wfPEFZJ+>Eoe{rB-UTe0b+@U`mBavL@{k#B{G z4IlG^0gQS2HpfONahBQh?T|ug%~!TcXDwB~8itn}WFgE#fhtVQ&<%12O!>eu$bDTP z@{p04dE2CGR>!~R_opyzvoOXlV;zHj`-D#ZTCbE&>}3T@n%^ny@QG_~w8~WMIkneQ zcl=1fNY}Pq&U>QbDyKi(P%WBqtv?ZjKWP|>L^s;7h{9R8so!8^Hfw9@(0q`SW7H`s zYIIo#F&U-`fPL>md{|r)z+_Nt-t7BSKAsdzApc=j2QQ-fZD^mIsv?4!mh*n&+Kg0E zx^vkysutNkkg!uKzj-sGUnBsuW!roKv0EP7OgVUNK`yIc?yKBY46)#~q$`LO2HmnC z@`FdiBAZg}T=j)q8j4jd_{5l;Kuqu8;tq8LD+3>$_kEWWbbsL`!=P+qJqfOXW=O(S zmHjs#&^fsI+b$i1BNUXuQbi1*(#L{iqpVuO&Q51LkLr}a^qaszq{Af+ZcnpcA>D@! zqG6R(fLHwq{{lB2tvuKj$kmnkYD}!@5Upt2MAqprTbz5>zMfH=j#@pdgcj%U*QxuD z#Zh;;kCRNq;4*Hb z=ep?P=Jcmm7AD@9$I=DvK70E$9Nsbz4nzT|L=A^a?D7 zsAMo$<9&g@JeR6*2ZZ^E79VrZ*VeOt;=sq^LQ^{SeRTM4Pg=YC>4m;IgvWn}7B>+* z2HzkL_Zd9k3?SSw9e{Oypt%*9;HW~MekbGVeg)O%_Z#Qb@9L$iGOcHro8-s+#QEht z#*VoUL86V+QSWsplN%!U2X~$3OlOEVxv3u?;NHA4cC7V%k$-uK}OU_hkbI9QEFT}#)4AZaTq zq7=Nek~D49Iw_#9D4&MiS2mzv24Toq6WoQu%M4 zFQPP`m$6KIcbocbVtX4SSgXK zB*J;S98I}wb`r`sHy7YT`v z9GFVNdLnALVDSsRL2r|Ei(MKvTziC=>H(Z`B<{ zgJV?dvp$^sy^`&hh~N9U`4&XhT50Qib{7MU zxe4FvixY^igcsN&Ek}MD0P}wz9&wT9b-Cn(y;o4>9FVK44Dn-TmBD^urJSlBiznwq z$>xP+VBfEvDm%l)$$5Romi+1WB|Q&+39HhMoT$z_+6HVikLnfGy*^NfW&$a&-J;@tc}4k80mzyM9HB=k0WvowPur2W5qx)A)L{%@D=b$yimqR&fIyPqU>*O+FKXf5>wRNl`k|p9QzTdrxwT!UV2t zUoHQRbTlZ#R_^@KRxr9Y6j`?EO=VXvou#8bmDMPzmw&xrD;c$#CKrk8H1cH@OoOxx z0+x^AMJ)vZGw7uDmI6bfW*ROodf_U?thI0- zF*b84q-&#znd_$;R`i$Y3U$b9d`y9B%SFBfwau(&RKZe8W=0rozxcZXzPrV zsPA%~IM$>qi%awVTx>#ac5)Y2Fmh{Nks93W~Lq#UpX82kXWeB}Ltb z20DX)S}uAKeK=V{F~A1BA95}EVNY-TRBR$b!TK zO#cM=9I}Q--EZIQ{)>Q%ljeY4Udy?0@!>TaYG9m=HA&CvfYdjBRZzKG=R#v7GXC`e zE_L)7?+)3A2Kw~d7ZloJl||DWd#>FD?{G~0yELE_!>yXADk~-`f5AE_m+BEvunONy zF{nLDj9Lh?qwh#|o`RuUOJwR&n<}3Y)=dlolO{}gjkudA-IVzowi-QkaXGFC1C;a; zM|!@zO^$>gMUIQm&c%v;TC;lDpt60;_%abe&wR||60+)>kt4Vg#+s9WOW;b7Ets_& zKv07(w+~jAr%~~E*}xv?t>~HHsy-FAo5TmIA;m$ctxS0hS*9|iDmCHc1F>NY)UvxE z1tGC2+V^h6Sx(aylo2NU42)kiMFR+Oq7q$p<~ol0hw&t+HhMe9P#HhJh+|R#M1yPY z5$_Ofa=oj729%H`QIk)8htUq9gHfDGB0R6YyE@_H(PX7p$cR}=mM~HYg;{*Ytt7}7XR1sJ?@>~oNwpObl-07iF z(O`5H&6Qb!-uFd2YsSvph1a{Aj=ynMa$T24kQajz*O`XO!uSPjXX#tHg=>6hLEYd_ z#0>eHSSw?zlWPf2;E%7s-}#3?q4}CGCtl~&oYL!PyJtncDL@Fk((LvbHLY_0@^hK? zNg7@k(<~n&Ys1{q=Bpl4EEAGvL~J72k&aJFID*g<$sYv4Iw=&NdTKI%5D9ig$+o8O zEyJC4aoHxyRN0iTboSp5EdQt>YdtT%UEDDGKjm<6OBmme(EEe~vCHniI%Do__vJs+ zjorKXtB~`y%Db$i-!k#FNetdMl3A7fSFK`2sFHY^@PCk#Bf|M3cuL&Pi~q$6sKx@s_>2Qrlh6OVSD;KX};{cl$T>QT`dC zrg+;XeUE6^8%}#`WsCehi?c0N(28MkW{);z_xVvOBC~ovh|N^#@@OW=&0BLsHt{a3 z@*iA9%quKBgxtV3C%qZJ_Gx1;-ggnj8J9)J?~-1!(wOl4da`I`<|DI#>lPWyn3zP& z#@f)h?4s?TO1O2k`<3TqF4RT%MMt;p?KNspS1vt>Pg}VkrV96VyjH(@LHzg5(Dm2* zNh!lS3mpsoV+1+(dl97$u4~RoUuwWb$~0*HJV;~KJjr0h0Y17iULr(4$h|D`b>~vXCdx}w zMIq88#B|``0$uxQ?I)DX5SH(Jf+d}KFtPWUN)n3qQ+A(Z#gJ6Id}WpHee`Y7L%VU? z=SYaO1tI#KcD=T6%2PE1K5LV);-_OE%Q1bAj-47JDH!dGabd#sD32zlc1OYHxRg7^ zKE*dnWkl*d{V+6?qU}tu|dVzi!8sYg;?eG^SU? zJEk~7)&TmPu}xuuca9B=5;3TuzOh!To*C+21MxyK78(PwX<=2de} z+L_LrSK&rnClaoH59aHecFm=zb{IZO&4Ts*FYGklL@hH&nfzui7~ z74!sKKP>Id^iC)+Gm|ekkq?aG25gwus?drHl769>txma=1MI!K0W%{Vl_CBPpH0dv z$b}!iXPDWN+T-8RoPBQ+b$E38o2c{Vw)JbIVC|Nm1sdP=bQWiZu-Z0tmo)l&mckax zn)*tlX$-xo0Go`rMz9G_6eAP1yPs2CyuDs+a$K4RP*;r-L7^DKMZU0sMRpzr-@HXp zKK2sHHl!4jw!sWc)aP)H6H9VVEx!lo?K*XhgGXBt(;lxNcup!nLDqN z(@bQz#)vvgBXS2(Fp5hdrQ4evzdT^7Q8Qi3ey0sB_o&aG#L>Fz5^DQ5x$ON9r-k|s2;K<^|Gc9YH>a(U~T z4tPFZcV9Rb_Yvi%IZZoo*e0gr8z0g`fu5m~>ojY8I5U56Z(?Jxxjq>mwiaij z!uTd92zU}iclqHKoK+|Y&usLL-N`LnN4qhkE)yKvRTfv#CJ(_xL`>D=;oEqIVA(lX zHy;I^d0!uwS#uXZLX7ELN8cO{@jzR#tKLdFDV)~K-mqBK!=?#|eAD8C+zYi>1!o z!CENLEnfv9hhY>1a&{aXt5KsZ0jmrOCgj0ahH&-;(d)rq3h7$!r4Jt)lg=*^z6o&3 zQz4@HIsc?@_vfm%iLJW~mBm$poW-;2X~+=;61QhTC67$5%E5MWs456Mb?4i+cngR$ zQ!wCa(`O+j{>dQS!zVRl>20DQNf7VjdOsdDHBlCh%q+V5V1krnC3)g!9J*m_C!apq zho{&Ey4x<=I3Vt9GaHd4<8r6GV()LM5@*Zz>=Vff3$7X(O1-fqfW0BHGh;)sgyEz* zkz<-uLZZ|3_NSILYOmg`eiOx9UR}OjVwVYWICScbEGx_JvNBZrxMZ+TGO;x^B~19v z-jYlyd&dsenw=9Ih9UlHqfS*RXIN-X+3+W~4uEoKGcgPm-nv3;7B{h5+L$Q;<(>g4 zICSUtz5ReEox>z8CohXOmy|YnZW;}hzjMry@gu*CTH_$3(@g%Xp1_t7cr*oJs55Zi zB#4`V;W$O(IJdj2g&LoYMzmTmn-Evkn3$6)kdT zR^#?9&BT4z=j)PnMFDn(K&ewkAEtQPP6V;6w0Z4ZWx4L@@Oytjo-xl1-W9=^CL%R< z^R|p@y%3RJ6p!Xg!6jcp=O~#&(71<&6g3uy*P0xNnLbWRR9*3N$+3Ifd`)HWnU2o# zwV0fOEC;KL8Ufu?8Ia>7|0}Ye*MQ?q%ozr2yJv{^DTUn@iB59d@@>_VyO5)+ z+6+6a%7cjDdf4tAlveQb&L07#2X|+!`>p&N#!*&rt~9z{iN!Q>%>~RaZPh|luaSW! zzmK0|C`Jh<%F)+=8$AkTZ6?(;wA8iArQ?h=KOi2GGM!$45wCL-Y(j5k#b2GdpqJ6v zNdM(mfisQ)&77P|+%1Vnq3rn)x}%BR^!CCH;+-M`Jy`$PCU}W_?Q8BX#@Ae5X4~X3 ztljN3lSU_nIyoOnqYej`10%r-gp3(F`?{3beWC+WCs$qEC%w(mD1-d^=Gr{$?BkAd zZ4m=8YG}cF{SmB`nx*S_u6B~EXAGLCI2{{cvFQpRjr^ZFQHVOWaFdk z&~19?)uT7sS!0>nl!Y{$s6o1tS5G9fowL zGKZjY9QR&Wp5>K9mfo^%PZg&S#z1on2r>YlYh}N>-2?^!%))9UQ67j{UYn z`w};YeIOxh3Y6ft#)jX`RT#%HOL?E4lvby2lv~|D?VC^s zR*NYVWiR}FEh)tT5T9(*Oq>}ajsDND^04lpI4`F znk~Q_J5w@n#=XFa&H}8$EJu%g6NE7x_%zM9xcMpqb(}poG#xeq>x~6n(i;8Fu?kRR z%fBX{Y}Slp@ZC*O%IWTXWANR|_N&&qJ4j=^*&SN^&xKj}yjxoCHLlrs4Sy}gM)3C`b^0F` zb|OFUFv2Wb_Ui?jeivD}i#=MWU+z1(4%j}WJiwM%@w8Pcyt5d31%z=^R5Qw*{;o0X zOTv)JHw#veGhD~!;{g2uB;^PVV^d$2CA%B)h*7U0XCHI;&oO7w|pZuGR49b#60D;H|T_^Cyd6|sZG z3FDsCT#hc9dD?cHZft+X2d>=>-NBCE;SH26w@q}Z-9J%VW!y|7?}1XtNlV)WPuIF* zP-A-r`Y<>bumL%9WI`UA>p%EZU~BWWJL*{qz0c!rUr7V~Apm~}k$%Pm!w)=;oz6kF z0Y@%Ae(1@Yllx;0!(BJd>Yho7Hxh!2-Qp;#G964BN zS$dUz5tiqxC`A-bXvSO9i*`by%M@?Z^!DPd-sDD`H;{QDH7gZESjTT2`j>%|)^OdTtVZ(e630Clh}a7g60)W&eIchd3&$N4m1m~`!@ zN#eWj+EU-R39=*&a(acRXvGo=yugZW)+M=0>{ThB?Q?OA*ZOo_O7NyQ;~NYaeWr&| znd)O+uv18(Lm-`A|3z=Nu~M_9#1b8)_w%V7+7CadonDDum;+$yO-J&)d@PYhP8jfT z-J|ToLBYg4U!53U6@PW=-Be92RFkV%{Z3_b)%ZkY(v;i%5-gT&~vi_!QFC6!RJ9ldb(G%uHybtqoZJ~97DgI-LZ-Bih=9ICdcZo8MFBGPG&^R zZNP^;)Kt9C_g`{)im!!#0IKtHw?<}v03qj(7Ln;zZRk2xZ*3j#mD?|SBUo)rtr3lK zOF|WD(2TZRHJR4oMEK?V{QpKFVHYI5GUN8#+s<2zdGXbmQ|x`(k=jL|&VJf*`LFS` zZG_U^`A%A#$b)-z20_8c|3Mr6>$>x=oxs6Ut@TXg*YvK87-lPO&|Z<}4(muLhSp{)FM6iqt{t+IfFP#jFaT2=S_v`3?$L zmQL^DnT(Ln2KoqM5*3qqdoMB}w5RbjZLO1RTR5C^r9M#6xPbxG)^M6gL4vO)o$GgN z0d!v_jYfMsSzuG`Yi(PRhkZ506LJA@wQ-0VHhHav=_{q%?gAQ`y)bd%W@%ZQTjx3b z*v2qk@SbW`%a*wtuhVQ_<@Je}GXLW=YL&pB`n5YQtMNt9?G&vcEp9_8rxe#gRnzUu zm$b@BU(QyhkAaOj!(hhhISede`54R4XoYYpq+27kEnN5Gjzz8BRmYFr!@VXp&4|tU zP4z(whZNP|4F(kI^@2PoQ7;&dAvLe{E&gK+5k?gUeQV#wPywsUa^q0XW;K@5*D^vJ z=t}emsWR8;1$m)B!m5h8B_qC}r)y+qN!+d#??OVTyXf=&BP`J5mWxyk5M>?atJA>n zdIDk9suuD-b-I*0{*&r_>jsZIG4z7WbPv*Mt}6{DP_WGAHO&OOvuC}SI+9N|d1eo> zQQ_r{U{B)Wly|Pb5WsY^^gu`E!O7HlIxZsDySyz}zS{0fn=f3PVDoX)4c@q~g-6q_ zhAWdf9C~s#LcIYbvc$$VP-N1g3VMUdWR!hg*i5rO#@-5#93ah%l1*j(y|6VWNQNYg+$oOyx1#rcreEdh2P>MdmPbLK z$?4*tR-r1q-j(xp`+eW{q|SU(So#G;35JC%fY#%4j(O8hG^a`Laf@i57&{Kmz_O-T6t zw=NRkIZH}TJ2%0Ba9X&CAnB>b+vLB;Er{qM01b^f_tj>idrjSn(w8T@I&u06K0il# zf92$|K6_bUv0?z0SP+$GH6I-4?~Q0BHLK+O_4blWUUsmCr_iG*!Ybu>LqWHq(4;Z9 z077Dq9@LC6kKx~iTxo3zM=Qs-BTRO@;9G3wY6%gd;fMe(K-^>)+h77mK16ndoqjLmKWY zT3X&HgBjH6z28kNC5p*M`B=A!Z5}#Ox!4$aKt9)438vWm4mA7&=&P7gm<0vCkkchM zpw*$XeJKBo;w3+fd|yiiqW7#X`c!lH(;F=(c(U!}J}Zr$RVDSl{-ZDJ)SR_PA9|YF z6ZQpm=quJ}H?VBUZ%-9$N~dawn9-I8Rj=WbOD33HkjV&B^Z1e>It2-@e5pn0^N_s$4F4E=VlJ;bpj2T1o&36t(k4y%+5aaO&>7wAoOw3tgdj>(vxhW$% z9>ihtuMj8&+IUt4d^ISaF>G$(+CnP_<>59>E)Eu&BGu)ZcWvHTh5rChnyKagLoC1l zvgon20mZ_-+udbqCPaz8;PpU=>w{;BGXx(?X>w|dGgyC%g1zglboJM==fZx$^Dv{h zBM&pLEU~c1YmbNzh*2As8)>nNcA@3q5V|zvwQN36QHjIHt^+{ICXKS>s#H)A` zzfjZ(l#7Z`5W`3CR3F;P@VEx{PK?dj`Gx)5A8~jQw?>93kgyoB=?HwX7cZdh=O}Ac z**^Ia-GIqOClQsk{tH8Yef7iK0h?KXss%Ux(ZKJ(_=leIA3}dKPB}O0L=rCu4T^U9 z#*;XRGO2i=`Y4iMhRST!@$VufKGnswm#A5md{lrEzIxutU`jP+4w5&LV_2(>{>6Fa z*~dc-1$y_jZru*LOiroIzld*4b1c}|y^ddfU7Xi*$g~!x4KYbn9PX$f*CWTAO`2(z z8p4-gkyLhog|=n#y1wpcDq6;~+4#xJ&l?tp55%%2nP@eh_&U(Vp4DClR|_*Oxsb;2 zj~JTI`zbBcM;R`<;N^Zh8aWOs+f^Q=Bo%f!P+?*1Nr2!0yB*JJR6P+gRbmGz@@SQf zG-fg2+JY8^qnxtU<$a1bIwD$jBXh>yFtM^&+Q<;Zp61D`SPfdM$ z1$fdp{`QAUC49e`ah?6k9kYec2YA$omz`>>!gSs0a0=r5|K-P!ew^MxiA{!US%Pk@ z@Pa1eD?jZCz(Xvx^naMuKulQPR!h#jGOTM#LnGh9@u0zX`EK2(-Gv5xpLQkfN)x8bC-qy$E_h1y1zatHcL~YnYgzKaGV%S$ zlR1i?>yw#Rsq|mRHw7KywQ``q-U%$3*azoycwwzWfi9u+T-K}nA)!)^@IsNe2}_0( z4)Osb7)WbHs1#YudhC)>36csba$_Hy8BCNS2zTB_M_0!%a074%&7E_Ue12sxX)XN2+(*c`qdIO5F1F< z1puC}JUIR`Ncmi~DST;&@l0?#-ge1K z8mu?Yo?aUE%pr%ab&lrAG{rpk&8qFyNzKdPcE|OtIH*p(HVuW>#F`haY63jOBaT$X zaZWzDF-$*7F~+pcF`kqzgO#z3eUv?DluWcWH(IYkR^s_8=@%!QgUHWKCF5kbC!m+J zAyb$E68>T231zmWgyLDHqsW#AjdsUZ5|`#bdXA*Snq(+WF}B+VwsF>z9=+u1QKu|b zgID1-9SNo-MDN7Dz#gh=!>q+Yr`X;2?NC)mXw*&DUM#z`UqzSv0YtP&5=Onisy-?< zEa*Ou2AYrD(ft9Wd5;?d0Fk_*I-_E`IFekw~){O7}q3tb$ z+IriyZ(0hpMOsR6D_*>~Lve@V?iwJtYl{|_;I6@;KnM;kP+Wr(q&Nh3r*H24-}BDi z_kQHllgV6@$;vF5Sy^jc$9bH;BT0A#4d_lkYR5@i;-rv57clVSgB0L)7SGt6?BnrZ z061+}+$}^OGkYCXA8k1D!@sn7ITRc^WK;|CUPp)&>(S4*TkX+n0iD6fzV*t*wsZ|s zU$k%g4$H zP`~=Gc0-H8k=!uBsnl$rtbosrKv*=P(Dch6IWBF?krj6i_7t3fynF=Wypv);YRi89 z%d~y=3K5k{sS}o$$3I5P*L3#nl`pnZ1fw197D|`G-iVHENQqwDN7M4l#&6g$a=4U@ zgOrk^0eJL6R4U&Bo~KXtaggsHo4mEwu9mZ=dy29Ji{m$K>}7i`@|oHYk6^S{`RGlj zk~?Z)zX!+iyO7Y2I_)GZIwoz0ho{*v$}mKJqw^0rR!|JKphYd4TV`>*UYtZV8{_5T z;n7&1@+zTX0o#8~1v;!bi#M+oG{}Z}&12U7nlNd&MpZ0agO9J$lPxW4xEE~tvKI)D zaf@K6q#HP8V-2hAmDF$Go$J6oS>uG_2I)AyXX@rzPqh$pQAAnNOUcOYUl5+)(DnPp z?cpZ+c2b|5T)F++rmfsZxG+UbPq(*>HQS3*C30?$#Q4odUx8fZ>6`$J=EY%kQgF=Y zJEYcrRnOew-}&-k|3X1AV=2bnKBPVFgWF`iO)I@Yo0?v1YPHw9(-0TzRNpX|FpZtf zURD~+7ap3p&;hAGV5tj3lQVFOw_=sP7mK{lwU(rAW1HQsoZq~^oIIpgN~wuZ^xTtm zP7g1lUmWCl!!cZ9Ah1Mzlu-ks_}y0HYIo|=^<0%{`QVV$p2N;|lej3h&kGX!^1D$r zjSJeXI7W#*2VfZus|L`J`vNFJJQ_)|3-oiEv8yZ!Qq?n(^D2!1;7_Vp$bH(4p3ZS; z4s9Y%9z&JoWX6!iQU6lQ2N_}8n%zh=*<=P_~ z6MWo;wrN)D$|;#0k}r;hY055w`|F`8-ep-@;RPL3-6wQqa2~H%2#M7d=T$|kLY0bw z^ZJJ(h}sXwH$zx)(0$P8hA9hc#py4`ENoJ`VE{wh(mI=~N#=w!1lsPw5Ri%q-(?da zbJ-X#wnR~?9zwQi;?PU)or;RQFs_x&kNEJ$ME2fSV{X!k^N)W<8d|px%_ZTFM@o$N zrv5`5Q`E1l3l|r@7!n_T701KZ4xWO>k7B-V5xk-`8R-gHBc|S145BPX!JC}iNA^Sg ztGlg08BWROLXo%nA2L<_g>aX@_gLQ!CC6Qa+b*+mRbKpGlto9p8(GjH@{_)Z7&3mr zFXb=9yRN zG)s(SWh)YjG$8+Nsa2;5XJXOeu}t%m*njECP1j-;U!iesyZsSp*?~4*I?KelXEx3C z#n<`>PCP3JKG^=F9nTelG2ar+MSmCe>ABc~L)R2K~Ja@xU>vTGv<);n%JFOCH&R7RcG%=I&Tt~^@jFo;mKt^KB1V~Q}! z_@X;sm859kiZoBn(6RM@PWzer8{jvLTLE4oW{wF!=X-mFJCibMp#Q3EJ?WK`>+Vml zAt#=>{pQaoB$uhk%T^b5%&r#Cc{Ph=A!~r8fX0r3_ef?e4k?=)h-!+OiB^f6I&*g;^REu3B^3_+mT zKW^+@udfMdmmjLBCIW6|;8oR6g@%%}mApKfd72ju%i8&Qjjv&{J^!@r4rA0G0Mu-- zBM6eI+X&kA%2JjYR(ipvW1^wZV1rI|h3O5LG536H673_C-1j6TP|FZW<45a%VZ17v z|6NuUa)~6J6^$z^MQbosjydWzK8!_Ze_%{)JA3kADdq{caex-8YpPjjuE-1=ytM|m zuaceZ107{7C&52mFeGDpgyVixq?{_rPh1a&#p}K!$jYS|;w4J?5J`}TVk)v(#EYc6If~DS?Xf8(lBvwRr{rm$caDq?#HOE-9WYe*ARJ@`SRhq z4A@R0E3l4+@p~r%pJmkbioFfV*3mw;VRqYT!YOMN(6cu!BU0&_vlJvuVN^V-*KqZ{ zcKE@FsY*Um*7!P$q*gx_Qt;i=&61c=nTi4M%B?@1?B=|`|KXPW3LM|T0mz=TMPx39 zUsyX2f1q6F>5YBZKQ%OMIGbhx&~USH;P#E8Kyq^d&mXndJQ7gwq8*WEam4A#2l2(@ z<=6E_cJT1Y@~Zb9i$;-4`{*vO()K4faXN9=s%=E5J)9cun zaSge>X&EuYNtS|eELmXxO2AeDSkEdsE3WmthVdg1JOR>Q(vSS8psc`2&Gr8Nl4EK3 zzHc>dyYaD{O{HwKQFOKoOAoiQBiJ0fuiIIwej$F}^lEw$2q#ZL9ZnH>YF@pVGXe-jR@N!AMZ~X_ z=>+NH5Y)WcYs}``aggh;^EJfj=}kvq5xLk3K}99T&&imXLHb%ZjllNl#b!^|cW(iZ zB+#Ex=Afa*XPrV7)Cv3F^%#?Onua;4Pr0b6xx`N8T0{dUeFHfP4`FtK4Y3x3*>>^* zIWV9FgvzLd2iJ66QplZ7b2J0iduGDS!A+amvP@* z%`0AuM2PYcjAG&ArkV3_Bsk8=%2DEx@i>4!#SR~k_d~VX)S&N|&nX!r*0A_<4CLA< z%-sx>F^>!04JgbxaB}ugAFsr|khr~w7p zA=bWau6c8MQ?@x*I+5 zcsGwUe+(jF-isw)$6|?-3FH;vv*`!9{LWkTL!(2N%*kiJqov9a3L_I~$`K>6O2z%F z5qR=tG4a7PVm88QJsS@<7q4RL7nEp#R~94-hMuW{RqUksx#3_&k|{?$sO2f>kMfHQ ztH|{+hZy^BFVOPB);+vMX=EcO2Fxo8a|PDD8M-aL4gC?O7dDCqOh6K(BlX`*89wzZ zxeYnw3bZdG4&ME);-aHcuhcx#htzt|%P#9LD29e`XEah_w=8ive7|m!b$WeQ2t_om zCz=4C)5S1M_!rDK5IIVDy$dXKDScOirh0Ma{E4W_RZP}Ilph-cq8KfP_ z@CWx%v-wp+<`{;W*>_BS+!O!42VLvJ$1ScVriEc%hd^Vrp_X4c_mU}N{?8WC&ArW{ z%6QtBCYB7FL3cE*+r;vad#Z-V$sYqh-TnmU;bK2u7Z{U?eAzL|s_ReK3{xw~D)ss- z@AiPW=B?_@ZS8Y&HrIxd;UXAuc)gNS@^~D!=UWe7GI8d zgMk!)hBnhoN6CM>LBZP+rf^*TN_z^;Ei2k=tL+{^wZls@^a>N>oh>)d=jeM-eDbqe zWZ;wxUVTKFYn?IJtJx+^Psc8B$%7(sAT%XQRc|BB+tI>=HK4aF=|K0rmlAKOX80hN zkt{u8QG9`#8TX)DXC!B)U*7V!OD~5SSa8+CG!?d2GVi$en4?bWKM~N|xO4Tzh{o%@ ziv=#vKCU_6&muv{tRq{3yh`^1wDPrtZ;oMpzaM6W3m~ zhOdUfF%T+EPZF1J_2oYDPW9CVmJ%*1HD-z3fS!`JBmfss_#}y z+K_=x!}>f$9A!T*;LpJzuOv$^c94>6T(|1r@2DX^7z>c09oSt_5j=H8;I?Z1{^PNn z4|X<4f2XC&UNL)}WENsHhODWesyoVrXtleAh`W8qwxtE-qu9xt!1Z}b6``kK;_!NV zQ5)sbJEvexxz@q@=t1;p{!jDlB>*s7GO2W4$sHbPpz|z}msca#{5=tjwjHiel~>iJ zXQYnssgV9=8rjhUEef+_;{#_pz_OVgo|Yrunf{tq+UB-SxTxIMJ$uEiTOX$>Y|iy0 zo+kiJ6!cr6tYX(CH$tRUPM)$TMxi%gY zda{+pK=lG7!Nq^HiV<&9Pj7yZG=Kav$suNJ*s-K9@Z|CVh7P_ z`RRo?i}$m|iqoBl=>rl0U!zW6qq5d%DLRC%i!l9#wh@abMu!T0M2?D88 zm!gi$f9BV=D!e!5vL`HyiNgJipUrQjF5y{dJ(98Y_7MYdf?w-dU1l?VDP3Ei8h2n= zLpePMe zz4(g)IBb(m8ATm}+-zDayn6XmJ>qjP@~jT=0^}IB)Hz5YZopNlU9q9mt^mnj)^@Tm zACKQrtH&0ELFPV=3`Cz#m1Y{F2R*D-_GBo(zPM*-8v?^b#t=~0IkUEX1s|idPEcud z1{GGHb{SxU*GWWSl3!k;#EK~99-nK)tFuLSn#g6*puwMoNj3jTj559_SM=EiBzYX% z$JFy}n5*}IVU_m_nS;}@QATM)_||^N5M6$F-}Wa9=XFd~iV5+vn$IZQiq`+=JO4KR zOYV7s_y#>SUo$_AOq_1`kp@l26|7eH=|12&hYbD?*OK>2-|8hznHM5=R_w*n2jo-p zxBg+VOPO-FTbbkezbMQkS-RaVe{L=wCllUA73h3hG}z;=h9Y`n&vvyxfw{R1IQcNO z%Mj-5qug-aT3Y2irbCVmDDY=*BX(~KPZKt4Z}o6oKu`d<24GIu>1iog|A_83|0w3M z&p^-Jpvkt9mQPGRvT|TZqrsXZ0XB6UO5FiP#O;g|rk2>wPJ8QRP7f_+?TnOKo5@HZ9ABVCJ>k>-;l7)ocJqy@>BXPn*KVu zwKxS{acfn0tG=#D786-S?TKBP%YmR0K}%03mDOH}D6u`tz#oUCU{0m4Z#g~^J)4}yf;h2{&ZC1>#t5%dJz$p!ZvOL7Q9cK>W&Hz(S$Pxv5()Q zD)h<_bl{v8%gC)_XAvB7R_8hZ>95yGuQJr|=wp^^ZE1=rKE%BsfB6vwmE7;c1-8yz zDnfN1ll{Tu2gQ>Js@79UE9kC!7@2)?fMM+oyj8vkgQ-4Sj>-aJKXPV@T)9sj521Z- zC8o?*y(z!%W%QX?onkCnwNVy*$k)UyNW4S-iK6n_CB~6?ci0huNb;VNotI6M=0n{s zW&)DT$oYxP|D9o?H)TWrb+6i_dd+8!6;(**t{3Q#z4i06-E;T5l;8bw=V1v%#|(=m z?jh}YfB+cT`~{`Sw(y<{OnA)_U1;zsZTZ80EDr9^N7@}Yb`;|hcQ8Nv$Ex-)e8iRs zmp|0)igu0oOmX?jX0x0bPY|&Qh+?(-tM=!u-h6Ib5q>PXiWBkQeJK=onSg@!FR>N* zj$^m;S}E zYxB14-9facswsShbR0$-tttZ@?Ap30C?T4={}9M%EEW^s7?b^_OqX`KU1OXbwFo+b zBA;OZkJx!cA`i{$(Lc`eIuGf2O?~wkbKS~aIlaWq;hKL@I8`WV=SFGr=SJVo9O4e? z0Uo?`xTkohqElC>HLOfBETG))$RAO|BWS=r16I>iDxBxatqVR<4`Jwg#_CQSQ|YgC)2gHrA@vcJ?8{dbGNYWfha=cu3!n|sj_*9B5yil3h^gX*4Jy@17*habmFYb<`my_deJwJbU`zg)&DvupzNqC{g zHHXb2X4BRd3Ktb)TgwQvFX|}T%YFJ%p)*XG2UScZUhe^nZIHgS+)&)G`?7bJ=lnbL_ddNEu)L`4v|TZtJw;ilp^(MlZM-y!Kn6{fO^AM=9Gx?; z!t|Iafi6lcx!9|J37-hfoNX+ZkxDM1wNsB7NRmQkvlvE@y8lJlc$^fsZ#-=|uN&d* zZ>WgIGj#AR9Cp@QA|O{tS8jwi>C&StK?>|R^Kfc#oMnfb9K$StLq*8Xi&Ocn-nMUh@Ku5Iq7f=)@2PWhd~u^{@GOL0t8&10 zI#?c)N$@jrvMjMT-grozVyM+(vFM>zrXNW(xw*+#CYao#m#g~P>{?@s7B8AtQ8Uy_ON2p~yPB=yhHq+@taS z{>2UqGzu-ccvJi}#6H?^j(cI0>gUB<$2SRKDxaM{9DN8D6eplk-$>uc3~LnWwdC(j z%k94ub82nlZNVd^q0`w~bV^|<)a|27^jc&lNoA1I2QB-AaW>E>jD+=&n$D4s4+B@F zJ?@D$#5fwglwDYkgw&Q8NWvc&b(dh|u>&WHAsRlU@0Dr6qkj*LQS;LCgBg<5x@lR{*me0gBm1>Y;EJ9V5Cyn`hGtye)lfx5Y~axwby98!~sJ)M&G$ z&5L-G>wElH4oPKbg4dcWSqy*n4FetMZQo#QD*a@=lPZs)%~X=8i0u2BW_bue>+}?m zzd(8My6iK`#_a=HnwZyD>57#`p?nostNoFadj5Qlr%6y~M~SaLcv(_iCmrE8&}%jr zW&enm+z91uA(=2yf->evU^A<0Y7M*FFRmTkm*dP|x@+Xt6?xvN=fMa*7%`z=s ztJqtPMwBqddx?UZh$OP?T1!g%h?S+QQ}+GoK>n*xz(~Ko+J~6< zoi25{PaCnstbzR8=!S8pq=4Is;ayG-5c3k>9kbOwvnsD|_ja$7g$*v2_L5JNey3KC_Vt)0V2CJ{S4*teohVX zMlPWFtvbzS%fnV{a;Q8qV+FtOa$hsMifnMtnG1(1jz#O z<1%w!+v*czz3s6E*q#NkHz3QdYA3v*>YY!MM{HLX&lVN9z?2L-gFRfDR^{n5@<~0NmuNE{xgxD1s|th(857oS zP7EFH-MGs-Cx9BE#n-d5q;59kWRwi_nM{X8*Ge(X>bc>Im=m%qf5t;N3IjSVD+^{1 zS?eW6U&O$YXey2wLi-@8Q7wjfw8psBB7?-IC#UOTnVka)s?9-G?5>Z^MYi9X+tc}i z_Hq92DL*$ z!=pU|Mb8!ouc0S-$(^V-<+<3et`tR1I}~o&tykj(J^2>?4-47L#ArC)WM>_I(7-nq z1s_Hu@GW#vUh=5?^O*m`|8#8+7wynzA3_%7AmjkA@&V?|g6$4-!DGP@cv}h~N-izQ z)tzTAN3F>B9d1J*inP%ENrGaMIbx}$R!25F7eu8H9R88}0@8TST76ix#eoj3ukRE} z+ELBpRalIFN5+rzoGPU)0aNvWj{J}UdMiyn{X^vA*EC;%z)|Tsh*o~G{W%wjR-v5V z5Y5<+Do3RzE6YyDQi5=u^i4N5PV_Iq3hoOIjcU7+1qB8z^F-y;lgSkT`A;$J zUieXGphqjwR%su?0Go8ZOEl0zc6W)8)Lj=h@I_n?RAm4hJ2J?Z){N~ENE%%VdqmU5 zr%+f_`!Ob6o)PR*|KKa<5&iEC}5R%Sy}{5%4OCCeOU?piGB0Jpb?X;c@55%!&YTcr%%)0(Ag%n zd7B6-62njHM!!XTdm9r+Q`XKO(yLqRK#z)s#p>LUixo%iH0=qnk3(PkzltqpX1SdC zkFHsV>GjNAc;&YlqUB%m58!?$ZKjX%vwu+rj0-X4(F_8zEHCQyRadqDk!; zasPaddq`UZY6@A-i4lgy0Q{Ea-`eZ5tt&O4psdIXV!G&8J^KaW6zD~dTv3etfy&Gy z3f(t>8#Hz)T&`NIx*EB$7GX?C>t5_OaOe zSRvz5*=Vm~eenR?cjmU!^4Nt^EJdu<8Khx|tVmAJ&ex+rGwiD5nk$78ayBb$Z2D1H z6JGBkZEa$gFD&Vm#dm@|vXbhYCrq?Oc=OW6w5Dpb3V`tykW007gxv-+){!s%peb_B zvyvRO6{8nEt)%F4KlWQUuNJSUw{!B5x;-Wmm)nkcH=4JNZOE)MMB!3idMIC~HKj2W zQm;R##}uoXw0D9e$B4Zm>_LbMobL2?yx=rUO!QTp4UCWe>G}@;@{%Y;5@BbOal~sM zunEM-?BjDraa>oaX)##T*9Dk6s9|#JwyfOlC?@$TH%?W4W(nJ6H~ET!#gqJ>7YM8I zs^N7}3=OYekP%XUQiECS`klvFrbZf~Ky2yN1f@v+Tq88Ox?b1$kD^#x7T2ey(uB^1ug9dT?_*N=2s-&151d!#AQ3!0Rd3%&Ag*|21mcM^PxSwI2eU|k z-E<*&CcLCA@JGR6H7~IKMt;kt4^JA;P$IgaqCWAVo&|9YtGTVimX?0~66rzUWO8}r z4_XDW>R#Pry)UArD^b&}CUmS_g_rTW{Uyt)+=nso6oGjvd>X+s0)#4;;y0Dj*3yQH z*#Yfu)U}?oWWAd2i5I+Y)K<$#w22uDSoo5Wh9h;ikW#t-wA92}7i*VNsy`XN?aNUy zZ;}AoS%IEn3q1&tTL4ihHI?3F=3`SZ}37&~I!&=)Vxo5K*-P@ceJGuOq z#CfH;s#6b7tHrLH9b`B6Xr5UphsusNpYvgRE2l12M-eLfS$3o(AvhMAtvgz{=*8z| z0M_+>HyEJ7AxjI6PZ-bm+VCLNe2K=+HdU{Dvu%8Lx#R(P~Ugouvi?8O zs*p*pUqV#LZSTx2auj(hfibY2>Tk_PtjK7E3IBRYrBCa}`r$SfU*QX+%n2*(*`J2L z6qj~7d~P*QJymWy=TPcscBYuVd~&@1hg)}RUeChB86WLoi$=2JVFZw4ye zi{B$c|4d*d;+C^n4X-hDn|<(>nBsPm57QQmbM;%%S>ekWrH!c}HrdVB#!@aH!%?3(ASQ`@=nH4}onklpcutF{xRWnvd)6+jpJz zk|&QFzH_%n#l z{}>XdHRXx~#+&?*;aQ-V+rk;z&aPSd{2{?t99r~U)6vm7btyWSjW6V9+;5c?w>HOu zsrrLpnS3NE7iewYkgWIK?@(j*^}a4tYVEdHPpf8GMsO;wM&Gdm*SrC{xKIc;-3kg< ztq8R1IQokcnu@#=10M~ZzFIj4Pd~MMJ18H&==0eZpu7Q;yQA1{f6GUG5^vIeyuX-r|D%By z_yjTlIq#pWj<0r^XsC2;SFRl2$UD|vtGS5PfqOl#g$W^b`*M zMTxIMep|TlWNZFJ5xsOt($`NH96>r)&nstk|DquBMjisF&SS%Ze2Kaup7JX$gPs`9#VF+h z=6X(e_>Q?M*M$P(AXni|N-^w+gEl#if!O8$AB>w@UNDCtZc*+EyTJL`dcA#RXIap`-Jwal(zkNp6wk5=UIb0dkGd8$}T(GLsXB8C=kdy}VT zjI>2kilk(!ujs25+SQc4cU}KoS&9DckgWM&UZ;I&Hbdc%LzUK$vT_a~7JYm3=8Z}0 z#wA z2`JbK3qD0lz zOv%heJ9Q}4KOn%6bu)N+&u(LC-IXqV!NX;1i*9GotJe~wfWZ;tEf6eSa%4UPYq0pp28Q2d_qcna-+xz4rms9F z(v2P=nO6D{IZLVFC?}rmI7qgL!6W zLBrk38$nuCRb$UWxTx7M^K>3$ABp$oJBxqt!W>Jy|ye`f(I1h1c+7c4)bzdDQ9EJrUi&z+Co^ud8 zOFp0r9Q#Z0s0Z=uV?oEg?Psh4V2A05_weQM%o+T5fJv&p=}?6RD`bCcl8`qM089)B zC;itX6bmverwHVR18AwBaB;eqz*p4Bc0Jk>TB#&zYAQ720hQSqt1L$wA3~m7bv7<# zlcoclZb@r!)W!$TJpS>ekX4B+#ss^3GZ&+P*ODWW9lio28QFJI`_Z|=s-UN!XV{%{ zGm)n%;t@-`>kiuJ8_5B=e^EZ7Rf_=mW=u^#xw`w8pOpG|9W-UGii?^s4fvwGs8}xh z7ay$aLVF(krgo$i*Z1y91GDY<_0iI-ld^Ch{=fTa zjr#MdGqeO)Gp^@7Hb0Cc%Xq;B*CI{5-V%CG)-v5Vq0Eh` zx5XceH7sX1*qF1`v(yt;`5Q@;fQM=q>4`i$go{LEGuby38Gt+Q~J-#r<6Kzf=IHN-xMH!L|y zLXVYsCoW0_>h_Cd=EOJ&l@IIogcsLqh7LZ3Uvh~lPL2s*Zu^$dEv>*rGT*~hZVy@f zzVlB~OZY=)fxpiznG4M1-6TuX&6 z5egXx7iSoTc68H@=8-VP#EBCd{OMu+G_1rKS^zeXWTko`FF^{VMdqOzzep~QWMo$( zd_HAw1>_MW4egcDrFN)Az0jwQd+gjjT+AXZA9>Bi(&$ldx6@u1p|M6be&$FhCh?Sy zLN&uVx2i6H2+MDjQ%IK2DbwU2Tt*@%!KGAL7r*y7k0n)K$jB%h|v+`(Xl(b zzTaUoG{pFjG#*LmF`n6cU?C=cJiHhTY}JZXBx z2X8*5xnzV6BV}Lf!JnRU$ z8S)$I)_ka{4Usj-oE#UHD2b8VwU2gS{b`$DByGj=qTV#30vo;3@hAo$u52b|c^Xd{ zow)i1S8MAg)`9}zMBKa*=|MvJK)PQyiGNW<0y5w78r2=iSfGq8W)3G-hN#emreE8y z@!WbXt(FJ2UGd16G%QaJ9}YlM6BcG^_uqf|HV}B>_`6*fd zneD9v=Cqt@e0*u}W=xRUBU;4Cr-$jFFYdJiH7kCqB{Haupkv^g7^6SNsfIAgyMxp` za+$`Dk6Ff}J|{a#w|7%r&pKCTRXdtOA4*Ore=D7ct6KP7EMcgU>o?8jv?%xfvA#~X zio*~&=pnWLSS5(5D3}GXb?n&VP+uxaQ+zS`F`Elm??FDrfHl0QOUKgK#;m1?W2_AT z+jC`CH@mY_Sn*t1 zjUpvshZx%fi|3Y8^&$72weV83z)^YMVyuB~Z`&)U)?r)=;qz)}#YiJNBxt5fZEdjj zVN%N*i}6L_nJ*Bmi^Ibj%=0dOw8pTouEXKJor>XHStdF0#Ili9VUAPPpIp3=3fc}{ zy9ShN6w`&PpD#em^`$fhxqzvoTMNmlJsI0{Oqg{D6^5(7+Ab+W8RP=KRfn~}HG>RQLF>wT+5vW69D3h~CA z?1xh`Qn%cV1P?rQhonaeo2tolmlS+f2LjsnRW>S=KBYMB0)0Aq78KH1wli9}UK(bz zE5SyTuE0n80MRD9BV;k!)>kZ1qG13$FXPG`U^HBjCxv+A&(}EcVP>+F2c)?zOwK|J zv_6(6t+TK$y3+Uf0zI`oNSUAoGi-%k*rxmr$aL7*)lY3@vjUwG${DTcdVLn^2Ne$b z)iWR~`YzH87cVBk9Mgj`W7^j8;l11pQxbt-3lfWx=5VoZdsn1dBxyvwrn*%NjoMfJoHT<>u57r@AMV`dp&a+RpN3~xGHlytD+_@$t zI+KZvhWzT>+EEnOG7z%ozzl~($iA9#dBF|2VVN+baBD_%^s(qpSoDXz=6bs(X_j6` zEo}y+k%{X808(k{hu%(Mal+ohm3c#^*OtLE zA?dP=xFLwDNErHinYRjo-LvashJsN@Lwy@3Y}P>zm&8*l92Ph_Q(Nf?z0{d4bJyhH z6xMJe>Nu-}Xh<>7(cLrqoNJE|Ef@oz*GQGM(C04;4SS5pIw~33k@XZ6^5q1=U^^QD zl)QN*_baP?DTg3kkZTN{21kHttZ1N+aAhsv@Z$xDYv2?VQb~Hifj0*s+eSZS~phD_sO4 zSO7DEv?IkN1*?`PB{DAMt!2t2l;0P%%jRX1sqKd>Z3XZ-=h#GX0{r~~DQ~{}UdS90 zXxa<9D3>}-F;w?AI#0a|QM$aE?6Y@yR6<^bihlJs;$D(aP1>kr)KPR%amK4AH`me# z@HAP%)7)Okr@6;8{~3r+`ZF*bCwI%{L&N@#ewfjhOJ$na=iieoNG= zZ)8udHQl4cC8otRY+-+H{7b&kU0p9aE!#gUN%dw^{Ie4e<)la;v8w;CwrcU2>Ny9b zvbZ?ae=+5HCmU^NgyFHJsahYpu12T!$`YF>mmz)Mt#v9Tf>M2`FLS{z}Nwa(so64wjzzkYd&l=FH~%` zPoWHB+e4z{G1lw6bROK_MLWU!ZySQ%q^I7{iRom`a)`Z$n8wP{ z__iVxNYx{8NXY0u)JSjigRWfq%K|YKGKZTvnc0~0_2(4D8=26u`OgdF0aBLWP3h#R zD&fT>YncdAb>tDTVg1%3m^bGh#VWJ(h#?>h;ac6;F;eEr*SQgQfoa4mw~d9HNFDKDILmqBq8azic4OW5;!N%#{JE;lD?#~zHel_; zQTOCpkt}JQXG`>Zzgvr3)!^%AD@k@PHdHN`j4vat@9$ZKTHpuv?Z1v`46%PVqV z-7ZP`G;#pqSH%<2737tb!q-*5nX*eCO@MJb_K3FOOsgjsZ$ej|9 zU8vj#!s}BpJGE3LT{NUFQ`Ifbv)jD;j>PIRunkfUwa+~oGUqz-4x8V6sI4QoLVRki zI_`nMEoh4Y36`~B#7jHUff0A~D~os;D68iq_8ptbtr$u_9n$^w!atD`qENj)@J~G6 ze~kYNmfXL&GS7_a{-W&5mLi9E*RF%nHxztzO^s8|niiQ4zSBNKhR+DrkfM2@tn}M6_~aMH3DpSU%R1Fx$qeR5pVfqYju!;4kB; z90npeX~sFI(%u3L97$Av{?;b9_ch%l=WbH9k`zbpb6mQ#x>srtuaBfqJF_mwk@dV* zt){D1sn%&G8z#DZyq(XK_$2A zg%wJVLeu)iE zT_g)+@AUmbb;qFl+VUDzcLOYT0GOvXQAC^NyeOfWnEoKvPmH@d|*%tE$4v-yTs_o%|OEWv4X>QJ`Itf9SslhGFF_%kVu zsHK0$OPOo8Dqna9l&Zc_mj}6#1Xw%L$f1^1z6@9}AK3E7kl^DKAG1Mm(Rt0i>wRkU7vS_Xa-TQ5C+Te2e=qy&x+$l<;}`}Tz`Ty_`li7D_DQ>5X6m;A2bk)wP$cUj}C zKcDS03Uvz8KNiaWIsU&fRU+~IRes~}Lv4g-Ub9G>0cTGPVkO0DJ_du9idl***vepR zeRcrZtRWR;m?WYM(a|ghahh@N<-uMDISY63!?oMYGkz;NX&~ss5wTjE-e2-(uNwz2)$3tl_@4G7qHXcQlvKYen*T=(ZDw!qxTUpz}ki8TOy{SoGB1 zFwN%t9^Do;2oi`R3pi|C6M(!;81e-#2{LTCkmEF@`^N#!kF*19p%oUstHpw5Ro?!E zY0-A!7*RX!>ila8HA4U@Ls`J)oj!=1^C2`JCx#H9D)_s1cEy)uE8Z}f#B{ArBK|RBoucmUiAOvJ@4LU z_9tiNoH;g=$vl(HBDtHRsmc z1OIIAgsP*u3?(I2D*L#G(P;nQ3`2jF^=CyP&BdvG1~!QyRr$!i0C6UwgckSusQvYf zAVYFv-?+BEA?K2pzZ81C?;a$?ePeZBELys4w|AG2CypFCb=#Y_yCn)0c+;2qfChAN zG6c6@#&cMPJ%HRdA`828AU;yo~BpNk^Y=8ryf`G@RUQf(b)iw;Yx77 zIIIZ2L5#!!_A?*K65p+LULhJz1Xn~FIU3Tk>7#q-y5AzUe_!6Lw*w`?CA`P96oo_25>Lj5UDAtHJ8EYSBM$zY0_((#IKsOk5G^k7jYu-%uSs1LH+2gj zM~%~ttoD+GlN5l01qDK`KcWC}!@f1UE1SW)h+godB$d<;xy_qj0LlWjS}M3`zN6*5 zz%R3~!w#MH<4D?Otua?UYl86$J(3eB&CJ4g-ECZ2mQU}-?7>iFRk&8FeMZEoW1*9j zB)QiV_Tuc~&C#W;aKo&4wjtn! z^DW&+i!Q2Rz1_fwj*bP&S|fwx_Z?qnb*v4L`e}jc@&T}k;oQix>E1+TXxPd&Z(0FA z>#v9^YgE2;aM~yUwu^*H18ik05$AYrzZlipOS&xrMi7j%1+WyAy<*FX@ z;&lH?J$CnE*@LXjP^(b9!JM3-Q6T<0d71Yv>w>A#Tie*GWSD?(qpPqRE6G06+m>Q! zpar)Iw3`lr7A1iW@>@4Se#Yr^<_1@i(e{^% zMtpSLBYE;F*`u)y{5Pi6g_VPjQTEC+&91K_4QbWB&x@m<^@352vB=roPuSIg)>Iky z?!P!3m0Nh`%88pMMU)ZeTDcwb8ni)rQNyP-9ZWUY;(!DMFK{5MQpTA#3jIbbl!WBi zHgF@=?S*%LI7`?i-Y=WylA89O*~WT$r0T4t`H%%L$jiUvB0lq)@`dcba?THnB&1og zJK)!zn{p$2zYDtmaH(GnpBJGjJ2|CHJImEaFKp8{Q!exH)4{DI$^f4KqEkH7;4Cv zQ9~<9Q5D5*zcVxzxe$7>J`@#5oT5IPqwvdBj!|{#%WaN8!&n{zY-sSLa3qoPrxLV` zO-G*uEKbVG1cQ>2CO*BoR-Kiy>8QQoM%2#0J(c7r&iM&!%`>tXp*4anqG_c|1b#1C zU)~EX-BS2zAp6?&2L5`;i$cpbm~KeY$o9-#K*eL_ur}J}mp|8%iaC_;JMq#hx`uig9&@H#(1^rDn z{-F4~NrI5gOqT7qW10wF`G}?;acdFJaRCpG^pq6wamO6~AUCSTw?ti`P2)5Nm^8t{ z@6nzew4(~X|9azUJz1r9Btit%12`v{T3j64!{00Xj5c|YoBqZB$QIw3enf88pDf^| zC0_tpSSr^~Il)yZ188{vp0r~UO;*)@^C@k|hmq>d-f@ha(iu*t-G=ef)T;O&Qmi=>{=A`=Jh zr#?{V4Xl7NeUzQ5jBRqea(%YBg!^MiF(t8?G9W0)GvVRY$`EDawcL&9GQio>2Iqlc zf#-WdAE#}zu3Z0%b_mO<-?O4i_Ui~&GI0H4uiy7$6Ga{|0uUo1MMS3QQW<%K)^=Bo z@BR)p&1yjPr^F<0Wvx7gZ^VU*z3;X@7~lJFK@^(F=F=YL1z-(xI?UiACdM~s48n;ewY8MOZi7RKQZfA#a{LhufJ2qP(}S1 zS2JUjdjE;tkseT{DSI-jgA=T$k-0FE4Q!&*?5fQ3bAElRvL+P`U7O0YYFH(ot4X>B z{=F!%+!MA+lB>I#k-br%x6Wuf0y}29{oq=Bjw#4sOvnis=%FaFgi|cKnvc7*(lc!c zml^rxfZ}`BlHX6x1mb_N`N*XHJrXnZ^oja>?B+OioC! zwx4Dj+6~Cj`N;n2*jyUASGTYplCZ^aum4hF1W#7n zs4U1Vnf%94})Oom6rYvR`MJiDsF=)!g&?3#c>ScV5fVpn#k(-9N2Da8{5}h#D(w ziXOY$pPN1#N@SbIa8g)KC0(jYR&U-g)?o0DzPVRgVbQR^Tk6`d%M}JU-_*2Gr1JrM zTMsTMxfa-;y9UH4)A{_2fjAI!`*^7;nzwo^su|DGELp?tPP{JaALo-NNh4+*P0v4x z@E~%moBWeDG;g-$K5>A~fwal2a||7SrjRj5BzZ}j5P98h5mFWq<9o*W%|BR{xDjOe zZbMdIbhUSx*^QhghLoQDNpdrA%a95GYNimbY{#}Lt+mkD)|ZqJekwS^XQ!9|fbSiK zOck%m?XL|DP4)%TaU(BdzhaX6BFp1-?axpRPs5{8(w-jAv~H#?!Pg=|4+v2mmTyRt2EPxxB1~t_43s zC*oZTDX1{3#Ffpl+lOW-Po5F9%~O_G={>2un`g41-|5EXH7XM(2`u4x8>(+a5Od++ zpBQcc8y1gZg)JCXd0+$I7Es$&Pb$5A2iJQ%k6Hu(6kbux9QPtRAMU$E6*y%o2Lw3J z?Qa(|T;Ic*aJdr>)4cmwWdsKW6JN6BdKnJjMI5M8Cw>tPiPN?bHZ{=}j9?nz#l$<> zuP)~xHNs!h2w{C|eNAPLYJO?>yg6wNW9%=IGubtgoUFENFf^3hX^ztd=Kyt=@qzVY ztRczH>2~CxvO(kXgFw{|r$TiDIzCGV%`RI=Q@>Nf3MBkw+j`2PG2;lkkEgP#bV^&# zZf5rcxboeqByK`UJ=FLsxV+T1&sremsk5@!C~*t(5MM=3D}Bx{c)+?;FPFAPr!!^=zBYTZrR zL>v$nyXzlrmil2d4PHOCLcM&G8_C&;EP4$yk6a%yjc>79m=5u!=pGI`c~X+L;A^Hj z(V}sO6vi8quE2nKh^SGyuUD0CP_#pCiuIh&PR{Q8GwwR43aU)xL5J_~c+r5F^&FJ7 z1#h^CjUkke_kG$*3{N;C@1auWuUg+qCXl*$E-^K@)KP<(W>0E40sDyQLEgL^`B}fu zh;n&6@w;to4!{W`U5Il}qSM{%6RM~%LjTWU1lfNdP+&9IZcY~F2Kh~24}QQkGYQJ% zl)RGJ8*O?j%COD}d*lp+p0Kr7Ez|;i1}aD$_Mx#K{y`f%$UeuijE!A=uM>d}eRo8B z0qCkWE^MrENkd$vom4pbUexCNvTCQUBy@8bSDWoHXx3Tu;N23y*{R}9rze?osVIGt z-X|m~i-pd6VzEZKY}Fr$0Q(xhvGEbJLS3x_C%o@1gjb5F@^rE)ml>bdczgwo7b#2i z4Qx{d!Kf@n*?Mda>E`P~3$2u}??pV%+j{cI8r#$J;&`KV02`mGZG`A*Y|fTTcOlW` z>Ut0_%pZZp#Ny$>ubGjz=Ph`R8GqjAjxhnD~MS z&v4X9X>8rSy{?F{F11@!iYo1(CgtGRan+|NCFLY5SQ&$r^&p14abh|Z1@#Xe&va}X z%E(-$Hv0pBO(gbM+jIFBQ`8e^c4TY9FB3x+M5|L?V{e6?gP0awYB|B1es&u8ZD?q@ zMppm%#ml_`p+=Ig3TIJwgKcKlHWBgUONb|!2hO85d~hu*luD&p>e24$<;8>n~Rya3oQU(Q*Nmyb+04sw(9@hLqu`6t=allMEVMjC2P1j_^sX49IA z%W(B)24ppSIXxtX+U-H*HFOup$McU+M&^dQ8*aO-_d9?PKWn9Frm{~~lonD1 z;>$Pq-ekvCAIs{R{m z5o^naY6NZilE)ifEu!SVuR`X7%0RHE+IrN*`2DlDCu6Vlv=pH3tBXe&Npic*Qi4;{snfLUI?CybbiTG%isUg|1v@Hhk7 z|KYZFWLIm{&9-T}8@Wu!VD0Daz)v6YZM_^Zt2=0@%w&$1wtUoG>D_Ez-R>gpJ5s#* zztp(in7aQjB{3L6s?U72LTZ+5&guWRUNFc(~J$0ai zlY6X+KT9ZMMscB1MJiVB?--PAZ46pAytgCm*gvkmjc#L=mc+#Ge;1QS-D)~LO;wy_ zYwLB7i`@Cj^v1^2Cez%F8{4@(wez=6RF;V6yCXM#P9?CR0Hu{ja=;*U>}BL{P0RoX z&Ts-fyE-<}c;DenDy5xs=N+X0`JL9pWq9r3+G(`XM$>49imzKsHo`})SX0?{vd%;cnF#wQlbQ=%rqKr|jLfU~h6fn+PF9 zZhimyk8vJT(Bu;9Hw06Qm7ftZE%tE!4FeM@H2zA8cm}TpgeQxgHa%sR*P(*o&-~v3ID1rL|Tf3OKGH7HrMH?#GyQ}CR8)!es54Z zi-QiSs$|I1w)Nzz;Wyazu&oO7$}c;5vRof$Q=-S2Taw*yO`_+I9=0#fKYIN*|G1fY zw*)D%>9J#DTB)X-O2ccW#D?bxMA0Ek+-?n@3|}`Tws+Di;}H&FsYsJ%(oK$?IxxvI z7D_=u4H>H4bQ0RaIRC|Ut zYxl#`EKB-GLXL+*@{}BWCNC z6Rq?1!_L7v-DWv0*_M&cY`tb$@x6tXoUjtNQI{I}m2+5FrV{f?VhESwC|urDGM?J* z^gSe6aofCM)6dF&&y9jY$Rlh<{Q9_)Hz((bb8f9RVl%IY`9jkKozr^5?l)raY%?r^{ycYA@|Hu(e|KYUJC*-7T( zsZd+@`15I{y=2fy?IEXX_O;ntaQ{YGqZ`|tW0cC)1L?rJ>6l@ay^smRht%Xr8a3*s zhIXlJ&f>j^h0;nwa+<{)mD@ifn~fWO6ZQk?KYeCH@2v`4vKq%v&U}EPvcOE99DHr;2Z2OceLWDNz|r6gO7QW^Wwqn(0FPRLa2$ZE0Zp^7XSJMHQ&QjWnENJ=6!g%vBG zsg1$xI@nYfSD!oblfbjH?@G%Wgzp zUCwkVxfx3YCLHq-R^zu~x8Wfh=RqiQMdYLljH>|)I=PAGj4t`w9~zP6wTvK5o$B4_ zX9g3aRLvLhOVZ_MF}=vrf_IZ4y1N%VOo0r?j<6sVq}DY9olL#NNLLG|Q%O&K!iGz1 z26Vu!7;~-|QJTuwq|kOQ9v~jgY6Z0_pmSd@cf%&V{TZb-RZ|z?_?$4uJ=~_ZHoAXF zYqK@NayW(&*4cL?n#?aeTgAyG*hpHWs7XqVA?8QrdCpn8q)bKzt%*BAYOWi^Y6zyD zeViH*tt`=6Hg`1G4H}PIUtlnsUB*M61cV?C4kBq>el#a3@%S@&HlB0(pWs#%;=Ew- zkdPV8oN_NoBd9J4TtlPwHGBDA91mMDGyczMHX7EM{Qt=9ODViO-km>-y;J$|C|OU* zysEcDb37;ywECDi?SNr1*6D{CuA&SR;8pb^hx#}Bt!%`|{(RUSVTo%R7uHOX*Z9Ma zkb$h<^964RpD^>87xdX%mq90Jh%p3@%(KEh_ghZy4}!NS_y~VXKZ`P19+vmA|IDdRe^i$jIGN!c3!CQ+|xf|I_6Bg8nul{{@{t2r-MblX#M zu6WBzUn_fEZks&1<#lSiy=u&BJOiP3B~fx8j*KWO`guk;01$Q;doVouK)Dpikc9Lm zOe77%OG(6YQLF8<<3>TE6zIkgmSAOiP{^jCt&!g}M&%1eHZ1s@X%=sAXRF|4HM zU_-L;zPopE3OhsvE|%>Dbj$UnWoc`!$e{OG?5dH=(c#3|H?84(j|*9olAHPqUk zzpmcYRsKgHmf3rx&1&PI`yFE`{8^1?y#0#sgO<@ zM}KoktyA2P-seq4Y0{PzQK(QuV-&AbrOzQW-s8h`Ao26?56-8H!vE2f$* z5A>^4@MDfV8wg^aeXA5LE!_T)OCiN6t1hgjzYTVz65zuz$xDMRj~ScK%fG&z)0I_V zSFeg2lplG9Ze(0J*=`A0QHHFcowOLWMIP|l~|V;HY>eY~O5{4X*!YXD*r z_|SH?8{c%lk)NAQel(w(k<~G96`lEVhW)?^W=^(Jv_R#d(^>J>)i%?rb*0H2*4PTA z#7a}2iE|^hzw3lFi!$I_R=ayurA$-W%*OTY3%;moS4iv|xjfXWYd@+W>I~l?f(l%7 z;*UqE^;Bb+%&BOS*)xpw&;tv>)&}Vg z>?1=*2kbT!&H98R=^b+@MpsA7*IL}V4KLwm>!uIHb3f2DINPx)Js}c%*t0?DGt{Gk z7(&tf9&q8C??P%%W{N=^OC%G0;?~l4+d>E)i^V`@?F-%jt$gYKN#tt2{~sWF(#^R7 zF;~LfGuD;Ms3Jb^;`FZ4fS3LWzY()-&$67pQPw|YdC=9mX zHO-lr1DjW6N-A}2qwcekjldWSe|5_`NIwUg`9@Z0lK~PWSe7&I?(H=+~9wly^I4t#j&&>-91; zRei${y`IbwZ*ev!JYoeWBJY zqgd9gb|J^=S;(R1H`_}1+_d$$wyKWLoR1oMiU3y(W0ih(lWyRcO*N~TQD~lU_)#gZ z)58AYoEs=Wk>7@>Y|lnE{NxZCMT|-ZJ5UvL>k{bpX*gY6)#bg1i0L!#Y*7*^5VDnb z#*QGP4;UJFDNIZSzK(}=q{Z`J@{<1&t{ctzJnV{m9&i{TfaGvDZ1rRs=^Y+I*X8_E zC~DJuBY2nFoMTjDf4YX{HvC6!*e}4$5phIH_Oj`}{GXwtj-?Mpika1N&2RN^6XjLM zf_MhG&iqV}Y5^8{nQz@m#7`lD$&m z#RiuNBG7|gNAN74ES1yb);FXu(>hB``Gt>tH78&_UY*--Tg2xBmU+PP}csBq`xpN=z74;+MTNdLO^njWX-sDySc;1dml zK^Vd+t#&V}N=)-rogVw=P>P5O5su&`dfECrSBcG3|1ax$?2MU1=!!^_2J`Dfo0=F`QpmB z_2Ba9M$E(GH3@J=dh(`18O!?Rzf2}AV+)(!+WZ?JY<8x2cJlZFVp8-yipA)& z&H;SbsLP3s`RB7m%dB!^mSrQXDz1ihuI>P~FDUgiYf_vPwFEVSdSWLS+aT`wBx5P5n7uhuzfp$}p#jGpUwMgYMPme0`$)S^1`*+f zfhWfjUw&un`TA(=9l5V~#$YkYyuo23X6PiXUCazMQb%K#4Dx;Sl86ot1Dt?qMi!B~E@WF;dqX3V=o3{nr?E$-{ z3vv8DeQS|%gKp6ZL{@U^y=4k}f4HT+-?c0KCkW30F}VMAs;7Y}BT!5;rbzQYKzK4L z%^UOlG@y4QuTFWK1YK5AUlol@x}fq4-W@>yKg}=rmokpZ1ep=0ej_QY>8p~96%%h( z(}x-#eD#6CsPYc+Shc&aVm4K-aVXdP0q73J8a?Ghnz=}%Lo(*r zdJ`a2a8}Y!#ZqIHjKa=0sgw9Ms;sAou z2B_6jO55$}Y!p7_C#?H$hlww(TVY9`d!UuI@+E0>--^hG=Bnu7p8KA0@$Kthj7mDD zJ7UXD#R6K`7GcXTi_SA@tlubq&Mq3(1#|0|Jqi+1ki-`)Z6mo$1hYc2F_t-0>)vS8>yi*Q*~*)AH}*W?NR;YuP*{Oi^JPAjb-9CFaPFB z$i-MrL96ly_77QD9SciZ``=`Gt1d3eSW8ka6!b$zvD3>i=?G6eRXv+7m48c?gFH8C zx2AxT>ZuBlv48>{PXPkG=m36&!ZIaZol*l6y)QFSOg2A(WMGp)VoC+`eH4qoR^vGA zb5c8-Al~rwr-g0u&?JnuAJ3)+23~!FuYDlWR{DJUrr?tYSz2R)z#$miBmb0w_Kcxr z2n8zq;D6V;IhdzJ-8=Y#1~Z=-ehdExExjCpamMPX#$KK5@!jz98lZWrcrnW-I6FTA zAU{_UJ{_1Sq+K*QuTE(uWw@a)jL-7?BqVXcCxKj;taggRb8Tnq3h4c==)>Z?Ga-0o zLzpAOpygzx3{Yw@cj#B0!I@%0{m0i~}~eIyCiigGV~ zQIiO#PjNb_&D^x(a5K_<2SF&F$@F=eVT-VQqsrd;#;4M+ME;EPnz9p{5!t1q)XY=s zu0Es z;ZU2bKdW5n_G33N!|jd?B&BNuxeRkIxcS1V%d724j#cirkE))|aA#NMer68K8MWCg zcd%FlkH2dP?m!(z&xk)_NkQ z&C6IptwFF&0)(P&+%Alv$i}N}p04{(-K#i0P_vZ3xlYI>^Vs5{F*x zqm)R>65lDWfaJz02MF ze1XqQktj*7#DuUp5Cf^1Gsu0JOB>fFO-K_-w!HO_2$YUN>X}Ug235-u4pDVS>^lnj z4C$82u`Q%N7*_VDv<`HdtV{u0P~!dL$dhjk2%m5Tvk={ebS(4Az9KgxLpk=ZW5X8R zwjapDRexA|*R=}0IM;U%+(sB7`$*H@z{MpE*4*Q-dkCZAJt?Dpz8>ZjkgJbKTN+kB zUtVXL{6fS$@fEGrO!VIcuQWvQ(fIG#-fqZKU|pEo`XED4+ri_zgWT<#utG88LCX4x z)8+JJMaX7~0i)R2wly&*G3hfv2!k==CMe_kSVm zh8i+*>OXHC*chCg9F1H(z@*u7^>yFd1~aFWh?m#?a&b12#0e4vn_qbsGaAxc6c-kT zT}-kgfrTX4nL=GB4J1>>yBNXx3m)+XF2FZGP6cqKS}lqpxuQrJ2XK9sz3@~Ge_46# zW?c3t>nR=|8Si`fBI*DKU>XvPM}*AXY%rN}XbN}s)yR*?Wy=;LjxAYrfF!KyXXwe< zvl;f7GlB?af7>&ZHREzjh4N<{wQSxYA4h{^% z2frSS)Vc8T+q~BajTpMfS)=X^yjwbsS+23f8P;g&2w)Tdt!|_wNr$Y}NyI0m&U~Oc zg9$Q>Y^H1&e6^3p9Hb|85z1?(6l#v$`D*{Op%UK3=}?^C7suwtUHGIOvk78^>i%86 zNm90PUE!AORAT=Jt@yon^r=sT$Ol`Ucux5!|O*FF<~Q}3p> zS*(d#FC#RcKhd-L)(K;7eGy;I#G|v3N?JgQ}QZ`?zVL7>~z+g*r1Fffs$2d<}2Puk*-n>C3J zx=#=yaC7V9znSj6XTxs$SH1y3lMA9^bII=f9U{vZn^)KH-c~^pNS2vQAG!9Bja=^c zt-4gm+Pq5}fT#_hS4H%O2#CK(M$TJYg)yeD+R-5BB)V5`yQCEV28-o%6e?&mJ{W_~ zVKa9b#mgWLok=n$y>BMO-Jba#+^D8fflbH6H;^GjbgOvlAElR zNj*?_PdSe!UEO`F`iK2G>o5y&PP$2qB&;mU=(V2LSlJ#(TRg7&b+}5@;x7V#DqdT? zPB(Hver7f$`83z3>V=83cPD_B}qu3#d#NRMkuC;yL0l#;BgfuDD z2az!`>t`VTf6$y?efgPGQQ15lrE?dO^+RPW-M5FjC{Ui>L?nzAzw*R+StYJIXRb?Z zCGSgEhMBmz5hYW&Pw@LG-=SOtGKhJ%iq!i^2423$Co!2 zAcB4nqH0mekE=XWp!j`;dZG4yccopLQj+P~MSX@KK^-~#VFsHYtB|VGL_ejz+&Fm9 zo_Wz&HpH&5WD2vStvqELGw{4jX@#?qNfYV#!>9!J^88@*;m~^v~L`$j`c8 zz`QIORoL`t-O2;YJL##b6AdlNW;(8vx&2IQw`4op%9X-7)X37lHtp45Ei6OzO~X~v z2EecEoTi?Ll9&0XqVb>=-QoLhj_Q6f_AlsL)Gp#y$g0-S(2|X|{_S!jLA42DztZ(4 zQo|xYk~eP7*fsizrp_@-gOhDln?!F*jY0=m^V74;GodaICG-cp^aIjNOcJ@`DRSNJ z2$c^FaxJX8b30nIP8xgu_qy&q7zKmzag@2;N_2h} zdp+n%5|_5{Cr71na=+YS)~umoq0wFBg;3*?Kr~VCAg!vBT#T-5+F)DiE?&}c3P5;f z>piBM{U&6e_1Rjp$mH$EbTQ#(A4 zR6JblM{qWPMGpnPwHn(z;Ewz3YR=YLziS;(ZL{K`@GM^--PqRLQi_+(vT9W#6NS)f z=s##)J(Rn2wIu-lnqgRq%z$6XNfIijcSZZLgm-cS^L*u6)~kW&_kg5SYg`*rOMvH6 z7oZ{jlbso&CsD=wa>dbL( z3ALOMHv)ukmMIGU!WL_L+Ml_Z2Z*hz-PEm=uIcPo41xkLK~H!|;@OX@$ICv%|NYx| z)gHLdeKkHW1$X0kqJhM>k*r3;YU1~b^8;!gF|MC>BjRBaLlY0;K_%62NnF(9 z-o8=?LW2(v3eJwrGV3VETlDHvk6Fq0l7P*gOrgI4c}0ho^ZzGK75~4TO0ov^lK#|J ztuY)U7lYxYJds2WuEGz+yod$A(ro_@D;kasMS7W7U*0kvLzgLl5ig^PoNnPGpx z|Db(erIvDAe{qCb$oc9HyMNF`9f;!_{y~%O$tzsPb@Y^3S3-TJ-!&&s?U#0^on9JU z!1Xx~Rn6HuwH& ztP?DCk{A>8Be{x|BXdZ3iI=tmE}!+rcJ;OqO~vhg7Hq^{7$uLh;tcEk!H@km%e;(O zaN&>!O@LkE-}|)!L6gL(wpl=~yVswJb&~3ly2L9@tI-FFD?e1c^zS~*Cu-XW1VQmZ zNbOyBVTa^o^Dy5BH;of6C-6f9)|h(NsJBiVgjqm)f6-ZItQ4ePl%38vx7<9tO&giK zD9T2eP8gnE8WDisUX+M_YHC zHkS!3+zwt@Q)~fS?~CEG8r&UDD;@h;*>@(&BIwy2ZOA*XxGy4n-kSaz{mZ*U>sn5s zwpQ{V-R)%UqES2!4&e&k2Bgp%3M34P=Z9RCW1%6Twr8 z&}wn@ZsWj9ANA2Pm-=}Qw5gfglnL{q29mK2zSo$H+!I{o2ghfrS$0MdMCZzYPdWTx zt#59JSET21$8>9tFZa(bFaG$-{XS`o$TCuAjOQ!}AKLdK9z0c%R?eJbOI!rFCGw=7N>iCg>e7=DnG-zZqqo+86skh zcF}>g{h+h7)(=OVmv0Kxk zoJj2Ytga;K7aLoHL7FK8V{4Z=H#wvL@)eF)?9u$OhW1XEODo^I%U8EZoY-O{T1%p;>bON1pS8m4b~n?mz;Z%0h;#_ClQ@C4v;y z!$c7l=}{~CFOAa0o{ygJ`IQ1f1|he4z1PUo>JTLjIY_ zEN5nSic5ohthAijU@X5U-c5#T#KTkO^LX+VFTI2&6J?RM?t@BABEY^Dw(PG?A=s{d z8^o-kiD&s+nzo)lLVknX&G|J-aYpWOHU1r^NOeEv;S;ZuJVyJfV)A8uPu$C+iojE? zxz!iix$+s=wc^TP>E|&kbO2t=wdP_(4Jghe>YC6Naij$6f`|>FWDl?Bq->23Rk${W z45CegA5r@wf~;)+^{ile(Zr&aUGMgvD{@Ufry0*e2Z-DAGPv&{Ry0sTf`N^Vr9L-R zLU=*QP<6~5n5Yegk4g8-Z)RR+T1Q%6L42-BXQtl4H)1SOiVV=DzX34* zux1te%faxJ=Aq5Z!Q|#YWCGnXu@wWuI<^LbVvIRGP=f>(p#t((x0)R61ZGPgvC+U(EQ|ivxM%_wE+IocUGdgfrXaDTVHk5 zvNmM*#*M?^N6M_TJe2g>M2WM+FrbAT%yx#_T{M#-NBd*j=(!D^c4>pT)OI4fiW~ny z!>sA@0u+92wR2Su3X@o+1L$4LK!hD7u~3^)pT7S?us>GEYBua2wDY$echn2xMzu=v zsbzQZ0yrE|98#HE_4q{BbJ>VL1wG{5Hm7xk7b@DCKhpy0b_|t~^J{??a#LYB`lg zVPh_l*M{}IkA{f48N-kq`wnR(q^b}VHspd>;0Aaud*b$%D zMd;;1gsxk-&9t2-AAyR!nKG0EvI0JB5F+E_GKq|5z951wzLO8(W^_4}En<$Kn}>0F-3@xsJl{K&pQUva9aPSete&;=9YxeW-DI zfKkD$8vQJga?o<4-8Ot`y-_o=qqdBOk9bsM@V~Wp<^N1Le0=q#8%?B!(oBl1kZW|v zZ6^0Q$}!i>eWget=NvQF5EgUGJtm5ri%srh?rQEbWk|k{@AG>8hv%2?=k@sqKJTAC zuh;uJ73#1L)tit6L-#X1RrYqETTyM^F~@!MmvRQs;I|v8iJ!FYk1;*YJrord5jcLs z?}t?^zS%pu-Bs)S^b>P-$i{U>6i;!xa+G_#VyhGcnX;XJ_EnH8W7Qf@D6QFP0E1SW zs^seM9V=sBt6&2Y3h-yL4{S?^F%CY>_eSJ-8Krw)YJ8-HU^>ZC5@8)_hv{bWn|PfK z2HchOMp4(~0#;Gu`cwb^MfXBaL?2Eg7`&qh{vFPtwKyXqbI&~S#TL8uNKa_;L`B+% zZ7r_E(;r;yiG`0jzrMLW!h6I)PMy~LHI;!}%=?@NIJ$4%V2!Dp3c)^1#ORCP6@Mgs z<<$ehhj6Zq$zYh%q=Z|*R~4f>^8=6V<)-zXXYh`l9T9u8h6NA3%z zeNg%xRO$a=AlZsr%i$=P|w z&xM+IvGZ*M9V$0CZ0Ay%%L4huDplY^^S|F;0Gt$S0UbFwyuay?QTs=cm^5N;K9hAJ z(Wt-ZORyseU0R=hQk_A6AZsawLl*QesQVI5vJ6uEs^bS?rxJeU(ghQWM5g6GrbvL# zPFA&T^#<*SCeH0dRj9Cx5kfzjA=N6{-8}2JXAL`qX~!QxPi>`LMF=h=9hY90^15^U zPcZjpn{f)c--p42Xqh!w(O0@O#5s!LpgS8nDPo%LoGl`fda2oq`mExLvh-3 zXI+U?1m*ApBEf+W;sjTk$E==0cSFAngM{m6HO}N2$^3~_rildvg_?6+OT^Cy>gYgH z(D4ku*kx{!cM+pg_FbTya2eT3m*?ed=lnaW^&VwD${sW?3}jT&!tlDBQ;}@A*Nk{E z!d%|n#OXwC;>Vgyn~eJrY1h{bC}g{tQH?Oxmj}5=Mkdy?3gti8`;AR(i!}`O8y8!O z2C^q+0#myp{-D(N=@MJuT8GP(xGk3bXS2@A7naB-xO&M_beWPNQJb)4=DNA2#@%_X zL}Gj`BMGcT9pKsv#-eraR(nHeFjfE7z0Cx1De)TKS9 z7^X0O)NG+7A?SdRWy}B&J&{ftE&G;|Y*f zFs|I2e(AJLcy5RvTz(}^IM2zU`y0|C^Wj@h zEp|)pmD4975Bb-WwD&;wI^0Qn)NMn7u({LWuNRIz=6*3Jh+TaYZrqR1{ifI6eGxf# zQh;hTz#Xy42>|Fu?Q zS%?+z+wL)OFVgT&lwf>SoK=wtrFrRuO-OcNrV)bDxfkI#G5tsv<6T}x(;ZPf^zabL zpF`RomsXeLQB6&@jP3eM>Y{fo?4RVw^rkZnsQ8fz$5&Lx<4p5Xsg%+Ai#|vn)Fw}i zhoFbmO-hS6^)}r02MnV&6i>O*RYz=ewb@|-XH1lXpC02Ii4aMO@M%yKuDJWxv)sD;Dm-11N z8=c{-6E$qp49pvQWHb%W7^RW1!5x^cLeh%g8xx@f?|ih0i?ndi5(`x|p;j>*UF{&{ z4&TsxkzO@Pk7h5i%vb|J^9eeNt=Ev#!no%r))b$pJk%obDZBsYdtJz!;j+)QG2q~) zfoukI4JET+tCs&{Fod3&5h+SOj03d2b|!zjfSMQNp#Sl*C3POfh$!d16B&t3Fh7?W zj}8+#Vy{`jku<3JT~ElEwJz9d?0jn_FxFRVcl19!+Y( zAFb<p62U}N8Fizssr6whK* zvDH0c))tm;Bvqe!t4s8{4U^F6C}FM8sJvo?XPp+TjuGp3q6VtL+{$TT)QQjLJ|#DTjOoc=?jwzR&Glh;~8$x+9Le5-O5$s{W?78zPl&fRrUzNw zbo+Me_1^P)QA*o9Df9F~L-ECAS*b-=w^$;#X;Ti&S{oU#3Z8LG1Cs_O2h%OpucFKe zXQ!f7VK3cr>AW~Y-sq@wMua+|>I1GCcz_3a$a zHeZdOprcch%#fX*mdLkuC>zsL2y9`51a${{eb={$YJXUvmlr>J$QlV@(gan3glunD zUG4b3o@lrC{w?S|Ps}mOK<#?Z{4U9Xv*_Iz!tWItlu4jvOnF{!)UU=WOAw-Gyx*XB zYo5khsMjvi_=p>^?bYMVD~uU1*4$)?0~xE3-xPl%-zNhiUnE|91kubd`=r|!FQ)2C04Y{G^WEF8(YT=u6(;BP3XVm-W zK@G)2iuiL|ucRocGhXYeQqsyfLtEWO<90RJ7qFc1J1b{`-FH8^zT V{=5Go@Gk=YBJlr1K;Z9*{{WU2vmyWh literal 0 HcmV?d00001 diff --git a/logo/iklim.png b/logo/iklim.png new file mode 100644 index 0000000000000000000000000000000000000000..a3443c7d44e5655d918edaa11047cd34431f818d GIT binary patch literal 19755 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 t.json) }}", "type": "array" } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [3440, 480], + "id": "a1a1a1a1-0001-4a01-8a01-000000000001", + "name": "Report: Gather Inputs" + }, + { + "parameters": { + "jsCode": "const crypto = require('crypto');\nconst HMAC_SECRET = 'c88f845bd6d520ded507ef6b02efc223019ccf68f41d9070705712d480ba5166';\nconst URI = '/v1/thunderstorms/within';\n\nconst ctx = $('Report: Gather Inputs').item.json;\nconst auth = $('Restore Credentials').all().pop().json;\n\nif (!ctx.t_start || !ctx.t_end) {\n throw new Error('Missing storm timestamps from Logic Gate.');\n}\n\nconst durationSeconds = Math.max(600, Math.floor((ctx.t_end - ctx.t_start) / 1000));\nconst timestamp = Date.now().toString();\n\nconst bodyPayload = {\n latitude: Number(Number(ctx.centroid_lat).toFixed(6)),\n longitude: Number(Number(ctx.centroid_lon).toFixed(6)),\n radius: parseInt(ctx.boundary_m, 10),\n backwardInterval: durationSeconds,\n endTimeEpoch: Number(ctx.t_end),\n intersectsWith: 'THREAT_POLYGON',\n pageNumber: 0,\n pageSize: 100\n};\n\nconst bodyString = JSON.stringify(bodyPayload);\nconst dataToSign = `POST|${URI}|${timestamp}|${bodyString}`;\nconst signature = crypto.createHmac('sha256', HMAC_SECRET).update(dataToSign).digest('hex').toLowerCase();\n\nreturn [{\n json: {\n requestBody: bodyPayload,\n headers: {\n 'X-Signature': signature,\n 'X-Timestamp': timestamp,\n 'X-Nonce': crypto.randomUUID(),\n 'X-Idempotency-Key': crypto.randomUUID(),\n 'Authorization': 'Bearer ' + auth.accessToken,\n 'Content-Type': 'application/json'\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [3680, 480], + "id": "a2a2a2a2-0002-4a02-8a02-000000000002", + "name": "Report: Calc Thunderstorm Headers" + }, + { + "parameters": { + "method": "POST", + "url": "https://api-test.iklim.co/v1/thunderstorms/within", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { "name": "Authorization", "value": "={{ $json.headers.Authorization }}" }, + { "name": "X-Signature", "value": "={{ $json.headers['X-Signature'] }}" }, + { "name": "X-Timestamp", "value": "={{ $json.headers['X-Timestamp'] }}" }, + { "name": "X-Nonce", "value": "={{ $json.headers['X-Nonce'] }}" }, + { "name": "X-Idempotency-Key", "value": "={{ $json.headers['X-Idempotency-Key'] }}" }, + { "name": "Content-Type", "value": "={{ $json.headers['Content-Type'] }}" }, + { "name": "Accept", "value": "={{ $json.headers['Content-Type'] }}" } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.requestBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [3920, 480], + "id": "a3a3a3a3-0003-4a03-8a03-000000000003", + "name": "Report: Fetch Thunderstorms", + "onError": "continueRegularOutput" + }, + { + "parameters": { + "method": "POST", + "url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={{ $env.GEMINI_API_KEY }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { contents: [ { parts: [ { text: 'Sen bir yildirim risk analisti raportorusun. Asagidaki verileri kullanarak Turkce, 2-3 paragraf, profesyonel bir ozet yaz. Sayilari dogal dilde ver; asiri teknik olma; santral adini kullan; sure, toplam darbe ve yogunluk hakkinda yorum yap.\\n\\nSantral: ' + $('Report: Gather Inputs').item.json.customer_name + '\\nZaman dilimi: ' + $('Report: Gather Inputs').item.json.timezone + '\\nFirtina baslangici (epoch ms): ' + $('Report: Gather Inputs').item.json.t_start + '\\nFirtina bitisi (epoch ms): ' + $('Report: Gather Inputs').item.json.t_end + '\\nToplam yildirim darbesi: ' + $('Report: Gather Inputs').item.json.n_strikes + '\\nIzleme yaricapi (m): ' + $('Report: Gather Inputs').item.json.boundary_m } ] } ] } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [4160, 480], + "id": "a4a4a4a4-0004-4a04-8a04-000000000004", + "name": "Report: Gemini Commentary", + "onError": "continueRegularOutput" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { "id": "bp-cid", "name": "customer_id", "value": "={{ $('Report: Gather Inputs').item.json.customer_id }}", "type": "string" }, + { "id": "bp-cname", "name": "customer_name", "value": "={{ $('Report: Gather Inputs').item.json.customer_name }}", "type": "string" }, + { "id": "bp-tz", "name": "timezone", "value": "={{ $('Report: Gather Inputs').item.json.timezone }}", "type": "string" }, + { "id": "bp-clat", "name": "centroid_lat", "value": "={{ $('Report: Gather Inputs').item.json.centroid_lat }}", "type": "number" }, + { "id": "bp-clon", "name": "centroid_lon", "value": "={{ $('Report: Gather Inputs').item.json.centroid_lon }}", "type": "number" }, + { "id": "bp-bnd", "name": "boundary_m", "value": "={{ $('Report: Gather Inputs').item.json.boundary_m }}", "type": "number" }, + { "id": "bp-rings", "name": "rings", "value": "={{ $('Report: Gather Inputs').item.json.rings }}", "type": "object" }, + { "id": "bp-rcolors", "name": "ring_colors", "value": "={{ $('Report: Gather Inputs').item.json.ring_colors }}", "type": "array" }, + { "id": "bp-ts", "name": "t_start", "value": "={{ $('Report: Gather Inputs').item.json.t_start }}", "type": "number" }, + { "id": "bp-te", "name": "t_end", "value": "={{ $('Report: Gather Inputs').item.json.t_end }}", "type": "number" }, + { "id": "bp-ns", "name": "n_strikes", "value": "={{ $('Report: Gather Inputs').item.json.n_strikes }}", "type": "number" }, + { "id": "bp-strikes", "name": "strikes", "value": "={{ $('Report: Gather Inputs').item.json.strikes }}", "type": "array" }, + { "id": "bp-turbines", "name": "turbines", "value": "={{ $('Report: Gather Inputs').item.json.turbines }}", "type": "array" }, + { "id": "bp-gem", "name": "gemini_text", "value": "={{ $('Report: Gemini Commentary').item.json?.candidates?.[0]?.content?.parts?.[0]?.text || '' }}", "type": "string" }, + { "id": "bp-storms", "name": "storm_records", "value": "={{ $('Report: Fetch Thunderstorms').item.json?.thunderstorms || $('Report: Fetch Thunderstorms').item.json?.data || [] }}", "type": "array" } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [4320, 480], + "id": "a8a8a8a8-0008-4a08-8a08-000000000008", + "name": "Report: Build Payload" + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.REPORT_SERVICE_URL || 'http://report-service:8000' }}/generate", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { "name": "Content-Type", "value": "application/json" }, + { "name": "Accept", "value": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json }}", + "options": { + "timeout": 300000, + "response": { + "response": { + "responseFormat": "file", + "outputPropertyName": "report" + } + } + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [4560, 480], + "id": "a5a5a5a5-0005-4a05-8a05-000000000005", + "name": "Report: Generate DOCX" + }, + { + "parameters": { + "resource": "file", + "operation": "upload", + "binaryData": true, + "binaryPropertyName": "report", + "channelId": { + "__rl": true, + "value": "REPLACE_WITH_USER_ID", + "mode": "id", + "cachedResultName": "DM target user" + }, + "options": { + "fileName": "={{ $binary.report.fileName || ($('Report: Build Payload').item.json.customer_name + '_report.docx') }}", + "initialComment": "={{ '⚡ ' + $('Report: Build Payload').item.json.customer_name + ' — yeni firtina raporu (' + $('Report: Build Payload').item.json.n_strikes + ' darbe) — rapor ekte' }}" + } + }, + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [4800, 480], + "id": "a6a6a6a6-0006-4a06-8a06-000000000006", + "name": "Report: Send to User", + "credentials": { + "slackApi": { + "id": "OKgM8VkM05pJl9kU", + "name": "Tarla Slack Account" + } + } + } + ], + "pinData": {}, + "connections": { + "Report: Gather Inputs": { + "main": [[{ "node": "Report: Calc Thunderstorm Headers", "type": "main", "index": 0 }]] + }, + "Report: Calc Thunderstorm Headers": { + "main": [[{ "node": "Report: Fetch Thunderstorms", "type": "main", "index": 0 }]] + }, + "Report: Fetch Thunderstorms": { + "main": [[{ "node": "Report: Gemini Commentary", "type": "main", "index": 0 }]] + }, + "Report: Gemini Commentary": { + "main": [[{ "node": "Report: Build Payload", "type": "main", "index": 0 }]] + }, + "Report: Build Payload": { + "main": [[{ "node": "Report: Generate DOCX", "type": "main", "index": 0 }]] + }, + "Report: Generate DOCX": { + "main": [[{ "node": "Report: Send to User", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": { "executionOrder": "v1" } +} diff --git a/qgis/2025_07_dagpazari_RES.qgz b/qgis/2025_07_dagpazari_RES.qgz new file mode 100644 index 0000000000000000000000000000000000000000..ebc9427e6ebe2d22bcea4fd41d782c1e15a0c489 GIT binary patch literal 17814 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 Tc(byBR4@TyE0F%}0^$Jxf8IW* literal 0 HcmV?d00001 diff --git a/report_service/Dockerfile b/report_service/Dockerfile new file mode 100644 index 0000000..a70d0d0 --- /dev/null +++ b/report_service/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Matplotlib, Kaleido (Plotly -> PNG) and python-docx transitively need a handful +# of system libs. Installing them here is cheaper than figuring out per-import errors later. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libfreetype6-dev \ + libjpeg62-turbo-dev \ + libpng-dev \ + libxml2-dev \ + libxslt1-dev \ + zlib1g-dev \ + fonts-dejavu-core \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt ./project-requirements.txt +COPY report_service/requirements.txt ./service-requirements.txt +RUN pip install --no-cache-dir -r project-requirements.txt -r service-requirements.txt + +COPY . . + +ENV PYTHONPATH=/app +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).status == 200 else 1)" + +CMD ["uvicorn", "report_service.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/report_service/README.md b/report_service/README.md new file mode 100644 index 0000000..59f3e92 --- /dev/null +++ b/report_service/README.md @@ -0,0 +1,97 @@ +# Lightning Report Service + +Tiny FastAPI wrapper around `create_docx_report()`. Lets the n8n workflow produce reports that are **byte-identical** to the CLI (`batch_generate.py`) output, since it uses the same code path. + +## Why this exists + +n8n 2.x's self-hosted Python Code node runs in a sandbox that strips common builtins (`hasattr`, `getattr`, etc.) and restricts imports, making it impossible to host the 3,500+ line reporting pipeline in a node. This service runs outside the sandbox and is called over HTTP. + +## Endpoints + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/health` | Liveness probe | +| `POST` | `/generate` | Accepts the `Report: Build Payload` JSON, returns a DOCX binary | + +### Request body for `/generate` + +Mirrors the `Report: Build Payload` Set node. Required keys: + +```json +{ + "customer_name": "Example Wind Farm", + "timezone": "Europe/Istanbul", + "centroid_lat": 40.5, + "centroid_lon": 29.7, + "boundary_m": 20000, + "rings": { "r1": 2000, "r2": 4000, "r3": 6000, "r4": 8000 }, + "ring_colors": ["#B71C1C", "#F94144", "#F8961E", "#90BE6D"], + "t_start": 1729000000000, + "t_end": 1729010000000, + "n_strikes": 1234, + "strikes": [ { "latitude": ..., "longitude": ..., "peakCurrent": ..., "type": "0", "captured": "2026-04-22T13:00:00Z" } ], + "turbines": [ { "name": "T1", "latitude": ..., "longitude": ..., "unit_power_mwm": 3.6, ... } ], + + "gemini_text": "optional — if provided, used verbatim; else the service calls Gemini or falls back", + "storm_records": [ { "cell_polygon_wkt": "POLYGON(...)", "lightning_severity": "medium", "effective_time": "...", "expire_time": "..." } ] +} +``` + +The adapter is forgiving about column names: it accepts `lat`/`latitude`, `lng`/`longitude`/`lon`, `current`/`peakCurrent`/`peak_current`, `p_type`/`type`, `local_time`/`captured`/`timestamp`. + +### Response + +- `Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document` +- `Content-Disposition: attachment; filename="___report.docx"` +- Headers: `X-Report-Filename`, `X-Report-Customer`, `X-Report-Strikes` + +## Running locally + +```bash +cd lightning_report +python -m pip install -r requirements.txt -r report_service/requirements.txt +uvicorn report_service.main:app --host 0.0.0.0 --port 8000 +``` + +Sanity check: + +```bash +curl -sS http://127.0.0.1:8000/health +``` + +## Running with Docker (alongside n8n) + +```bash +cd lightning_report +docker compose -f report_service/docker-compose.yml up --build -d +docker logs -f lightning-report-service +``` + +The compose file attaches the service to an external network named `n8n`. Check your actual network name with `docker network ls` and adjust the `name:` field if needed. Once attached, the n8n container can reach the service at `http://report-service:8000`. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `LOG_LEVEL` | `INFO` | Uvicorn/service log level | +| `GEMINI_API_KEY` | _(unset)_ | Only used if n8n doesn't send `gemini_text` in the payload. If unset, the service falls back to the deterministic commentary in `src/reporting/gemini_commentary.py` | +| `GEMINI_MODEL` | `gemini-1.5-flash` | Only used when the service calls Gemini itself | + +## n8n configuration + +In the n8n workflow, the `Report: Generate DOCX` HTTP Request node points at: + +``` +={{ $env.REPORT_SERVICE_URL || 'http://report-service:8000' }}/generate +``` + +- If n8n and the service share a Docker network, the default hostname works. +- Otherwise, set `REPORT_SERVICE_URL` as an n8n environment variable (e.g. `http://192.168.1.10:8000`). + +Response format is set to `file` with output property `report`, which the downstream Slack node uploads directly. + +## Gemini commentary handoff + +The existing n8n branch already has a `Report: Gemini Commentary` HTTP Request node that calls Gemini. Its text is forwarded to this service as `gemini_text`. When present, the service skips its own Gemini call and plugs the text straight into `create_docx_report()`'s commentary slot via a scoped monkey-patch on `generate_gemini_paragraph`. + +If you'd rather let the service handle Gemini end-to-end, you can delete the `Report: Gemini Commentary` node from the n8n workflow and point `Report: Build Payload` directly at `Report: Fetch Thunderstorms`. diff --git a/report_service/__init__.py b/report_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/report_service/adapter.py b/report_service/adapter.py new file mode 100644 index 0000000..539660d --- /dev/null +++ b/report_service/adapter.py @@ -0,0 +1,178 @@ +""" +Bridge between the n8n `Report: Build Payload` JSON and the existing +`create_docx_report()` entry point. + +Keeping this isolated so the rest of the codebase never learns about n8n. +""" +from __future__ import annotations + +from typing import Any + +import pandas as pd + +from src.config import config + + +_LIGHTNING_COLUMN_ALIASES: dict[str, list[str]] = { + "lat": ["lat", "latitude"], + "lng": ["lng", "longitude", "lon", "long"], + "current": ["current", "peak_current", "peakCurrent", "amplitude", "amp"], + "p_type": ["p_type", "ptype", "type", "flash_type"], + "local_time": [ + "local_time", + "localtime", + "time", + "timestamp", + "captured", + "datetime", + "date_time", + ], +} + +_TURBINE_COLUMN_ALIASES: dict[str, list[str]] = { + "lat": ["lat", "latitude"], + "lng": ["lng", "longitude", "lon", "long"], + "name": ["name", "turbine_name", "turbine_id"], + "unit_power_mwm": ["unit_power_mwm", "power_mwm"], + "unit_power_mwe": ["unit_power_mwe", "power_mwe"], + "tower_height_m": ["tower_height_m", "tower_height"], + "turbine_rotor_blade_diameter": [ + "turbine_rotor_blade_diameter", + "rotor_diameter", + "rotor_blade_diameter", + ], + "altitude": ["altitude", "elevation"], +} + + +def _apply_aliases(df: pd.DataFrame, aliases: dict[str, list[str]]) -> pd.DataFrame: + if df.empty: + return df + lower_to_actual = {str(c).lower(): c for c in df.columns} + rename_map: dict[str, str] = {} + for target, candidates in aliases.items(): + if target in df.columns: + continue + for candidate in candidates: + src = lower_to_actual.get(candidate.lower()) + if src is not None and src not in rename_map: + rename_map[src] = target + break + return df.rename(columns=rename_map) if rename_map else df + + +def _build_turbine_df(turbines: list[dict[str, Any]]) -> pd.DataFrame: + if not turbines: + return pd.DataFrame(columns=["name", "lat", "lng"]) + df = pd.DataFrame(turbines) + df = _apply_aliases(df, _TURBINE_COLUMN_ALIASES) + + missing = [c for c in ("lat", "lng") if c not in df.columns] + if missing: + raise ValueError( + f"Turbine payload missing required column(s) after normalization: {missing}" + ) + + df["lat"] = pd.to_numeric(df["lat"], errors="coerce") + df["lng"] = pd.to_numeric(df["lng"], errors="coerce") + df = df.dropna(subset=["lat", "lng"]).reset_index(drop=True) + + if "name" not in df.columns: + df["name"] = [f"T{i + 1}" for i in range(len(df))] + + for optional_col in ( + "unit_power_mwm", + "unit_power_mwe", + "tower_height_m", + "turbine_rotor_blade_diameter", + "altitude", + ): + if optional_col not in df.columns: + df[optional_col] = "N/A" + + return df + + +def _build_lightning_df( + strikes: list[dict[str, Any]], + timezone_name: str | None, +) -> pd.DataFrame: + base_columns = ["lat", "lng", "current", "p_type", "local_time", "current_abs"] + if not strikes: + return pd.DataFrame(columns=base_columns) + + df = pd.DataFrame(strikes) + df = _apply_aliases(df, _LIGHTNING_COLUMN_ALIASES) + + missing = [c for c in ("lat", "lng", "current", "p_type", "local_time") if c not in df.columns] + if missing: + raise ValueError( + f"Lightning payload missing required column(s) after normalization: {missing}" + ) + + df["lat"] = pd.to_numeric(df["lat"], errors="coerce") + df["lng"] = pd.to_numeric(df["lng"], errors="coerce") + df["current"] = pd.to_numeric(df["current"], errors="coerce") + df["p_type"] = df["p_type"].astype(str) + + local_time = pd.to_datetime(df["local_time"], errors="coerce", utc=True) + if timezone_name: + try: + local_time = local_time.dt.tz_convert(timezone_name) + except Exception: + pass + df["local_time"] = local_time + + df = df.dropna(subset=["lat", "lng", "local_time"]).reset_index(drop=True) + + if "current_abs" not in df.columns: + df["current_abs"] = df["current"].abs() + + return df + + +def _epoch_ms_to_local_str(epoch_ms: Any, timezone_name: str | None) -> str | None: + if epoch_ms in (None, "", 0): + return None + try: + ts = pd.to_datetime(int(epoch_ms), unit="ms", utc=True) + if timezone_name: + ts = ts.tz_convert(timezone_name) + return ts.strftime("%d-%m-%Y %H:%M") + except Exception: + return None + + +def apply_farm_config(payload: dict[str, Any]) -> None: + """ + Mutate the global `src.config.config` singleton per request. + + Mirrors what `batch_generate.update_global_config()` does, so the rest of + the reporting code can read farm-specific values exactly like it does in CLI mode. + """ + rings_obj = payload.get("rings") or {} + ordered_rings = [int(rings_obj[k]) for k in ("r1", "r2", "r3", "r4", "r5") if k in rings_obj] + if ordered_rings: + config.distance_rings = ordered_rings + + ring_colors = payload.get("ring_colors") + if ring_colors: + config.ring_colors = list(ring_colors) + + config.wind_farm_name = payload.get("customer_name") or config.wind_farm_name or "Wind Farm" + config.timezone = payload.get("timezone") or config.timezone + + start_label = _epoch_ms_to_local_str(payload.get("t_start"), config.timezone) + end_label = _epoch_ms_to_local_str(payload.get("t_end"), config.timezone) + if start_label: + config.analysis_start_date = start_label + if end_label: + config.analysis_end_date = end_label + + +def build_dataframes(payload: dict[str, Any]) -> tuple[pd.DataFrame, pd.DataFrame]: + """Return (turbine_df, lightning_df) ready for `create_docx_report()`.""" + timezone_name = payload.get("timezone") or config.timezone + turbine_df = _build_turbine_df(payload.get("turbines") or []) + lightning_df = _build_lightning_df(payload.get("strikes") or [], timezone_name) + return turbine_df, lightning_df diff --git a/report_service/docker-compose.yml b/report_service/docker-compose.yml new file mode 100644 index 0000000..f3bdeeb --- /dev/null +++ b/report_service/docker-compose.yml @@ -0,0 +1,39 @@ +# Example compose snippet. If n8n is already defined in another compose file, +# drop the `report-service` block below into that file (under `services`) and +# attach it to the same network n8n uses. +# +# Build context is the repository root, not the report_service folder. +# +# cd lightning_report/ +# docker compose -f report_service/docker-compose.yml up --build -d +# +# Then in the n8n HTTP Request node, point at: +# http://report-service:8000/generate (same Docker network) +# or http://:8000/generate (host networking) + +services: + report-service: + build: + context: .. + dockerfile: report_service/Dockerfile + image: lightning-report-service:latest + container_name: lightning-report-service + restart: unless-stopped + environment: + - LOG_LEVEL=INFO + # Only needed if n8n does NOT pre-compute gemini_text and you want the + # service to call Gemini itself. + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash} + ports: + - "8000:8000" + networks: + - n8n + +networks: + n8n: + external: true + # If your n8n docker network has a different name, change it here + # (check with: `docker network ls`). You can also remove `external: true` + # to have compose create a fresh bridge network. + name: n8n diff --git a/report_service/main.py b/report_service/main.py new file mode 100644 index 0000000..0ec9674 --- /dev/null +++ b/report_service/main.py @@ -0,0 +1,141 @@ +""" +FastAPI microservice that wraps `create_docx_report()` for use by n8n. + +Endpoints: +- GET /health liveness probe +- POST /generate accept the `Report: Build Payload` JSON and return a DOCX + +Run locally: + uvicorn report_service.main:app --host 0.0.0.0 --port 8000 +""" +from __future__ import annotations + +import logging +import os +import tempfile +from contextlib import contextmanager +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, Response + +from src.reporting import docx as docx_module +from src.reporting.docx import create_docx_report +from src.reporting.filename_utils import slugify_ascii_underscore + +from report_service.adapter import apply_farm_config, build_dataframes + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger("report_service") + +app = FastAPI(title="Lightning Report Service", version="1.0.0") + +DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + + +@contextmanager +def _override_gemini_commentary(override_text: str | None): + """ + If n8n already called Gemini and forwarded the text, short-circuit + `generate_gemini_paragraph` so the downstream report uses it verbatim. + + Restores the original function on exit even if the request fails. + """ + if not override_text: + yield + return + + original = docx_module.generate_gemini_paragraph + docx_module.generate_gemini_paragraph = lambda _ctx, api_key=None: override_text + try: + yield + finally: + docx_module.generate_gemini_paragraph = original + + +def _build_filename(payload: dict[str, Any]) -> str: + safe_name = slugify_ascii_underscore(payload.get("customer_name") or "report") + from src.config import config + + start = (config.analysis_start_date or "").replace(" ", "_").replace(":", "").replace("-", "") + end = (config.analysis_end_date or "").replace(" ", "_").replace(":", "").replace("-", "") + parts = [safe_name] + if start: + parts.append(start) + if end: + parts.append(end) + parts.append("report.docx") + return "_".join(parts) + + +@app.get("/health") +def health() -> JSONResponse: + return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version}) + + +@app.post("/generate") +async def generate(request: Request) -> Response: + try: + payload: dict[str, Any] = await request.json() + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid JSON body: {exc}") from exc + + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Request body must be a JSON object") + + customer_name = payload.get("customer_name") or "" + n_strikes = int(payload.get("n_strikes") or 0) + logger.info( + "Generating report for customer=%s n_strikes=%s n_turbines=%s", + customer_name, + n_strikes, + len(payload.get("turbines") or []), + ) + + try: + apply_farm_config(payload) + turbine_df, lightning_df = build_dataframes(payload) + except ValueError as exc: + logger.warning("Payload validation failed: %s", exc) + raise HTTPException(status_code=422, detail=str(exc)) from exc + + storm_records = payload.get("storm_records") or None + filename = _build_filename(payload) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".docx") + os.close(tmp_fd) + + try: + with _override_gemini_commentary(payload.get("gemini_text")): + create_docx_report( + tmp_path, + turbine_df, + lightning_df, + storm_data_path=None, + storm_data_records=storm_records, + ) + with open(tmp_path, "rb") as fh: + data = fh.read() + except Exception as exc: + logger.exception("Report generation failed for %s", customer_name) + raise HTTPException(status_code=500, detail=f"Report generation failed: {exc}") from exc + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + logger.info("Generated %s (%d bytes) for %s", filename, len(data), customer_name) + return Response( + content=data, + media_type=DOCX_MIME, + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "X-Report-Filename": filename, + "X-Report-Customer": str(customer_name), + "X-Report-Strikes": str(n_strikes), + }, + ) diff --git a/report_service/requirements.txt b/report_service/requirements.txt new file mode 100644 index 0000000..98a7311 --- /dev/null +++ b/report_service/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fea6511 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +pandas>=1.5.0 +numpy>=1.21.0 +plotly>=5.15.0 +kaleido>=0.2.1 +scikit-learn>=1.3.0 +requests>=2.31.0 +python-dotenv>=1.0.0 +python-docx>=1.1.2 +matplotlib>=3.8.0 +google-generativeai \ No newline at end of file diff --git a/separate_by_month.py b/separate_by_month.py new file mode 100644 index 0000000..6ae38f5 --- /dev/null +++ b/separate_by_month.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +import json +import sys +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Any +import logging +from collections import defaultdict + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def separate_json_by_month(input_file_path: str, output_dir: str = None) -> Dict[str, str]: + """ + Separate JSON file into smaller files based on months in creation_time. + + Args: + input_file_path: Path to the input JSON file + output_dir: Directory to save separated files (optional) + + Returns: + Dictionary mapping month to output file path + """ + try: + logger.info(f"Reading JSON file: {input_file_path}") + + with open(input_file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + logger.info(f"Successfully read {len(data)} records") + + if output_dir is None: + input_path = Path(input_file_path) + output_dir = input_path.parent / f"{input_path.stem}_separated" + + output_path = Path(output_dir) + output_path.mkdir(exist_ok=True) + + month_data = defaultdict(list) + + for record in data: + try: + creation_time = record['creation_time'] + date_obj = datetime.strptime(creation_time[:10], '%Y-%m-%d') + month_key = date_obj.strftime('%Y-%m') + month_data[month_key].append(record) + except (KeyError, ValueError) as e: + logger.warning(f"Skipping record with invalid creation_time: {e}") + continue + + output_files = {} + + for month, records in month_data.items(): + output_file = output_path / f"firtina_sorgulama_{month}.json" + + logger.info(f"Writing {len(records)} records for {month} to {output_file}") + + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(records, f, indent=2, ensure_ascii=False, default=str) + + file_size = output_file.stat().st_size / 1024 + logger.info(f"Created {output_file} ({file_size:.2f} KB)") + output_files[month] = str(output_file) + + logger.info(f"Separation completed. Created {len(output_files)} files in {output_path}") + + return output_files + + except FileNotFoundError: + logger.error(f"JSON file not found: {input_file_path}") + raise + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON format: {e}") + raise + except Exception as e: + logger.error(f"Error separating JSON by month: {str(e)}") + raise + +def main(): + """Main function to handle command line execution.""" + if len(sys.argv) < 2: + print("Usage: python separate_by_month.py [output_directory]") + sys.exit(1) + + input_file_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + try: + result_files = separate_json_by_month(input_file_path, output_dir) + print(f"Separation completed successfully!") + print("Created files:") + for month, file_path in result_files.items(): + print(f" {month}: {file_path}") + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/analysis/geospatial.py b/src/analysis/geospatial.py new file mode 100644 index 0000000..ebc9972 --- /dev/null +++ b/src/analysis/geospatial.py @@ -0,0 +1,51 @@ +import numpy as np + +def haversine_distance(lat1, lon1, lat2, lon2): + R = 6371000 + lat1_rad = np.radians(lat1) + lat2_rad = np.radians(lat2) + delta_lat = np.radians(lat2 - lat1) + delta_lon = np.radians(lon2 - lon1) + a = np.sin(delta_lat/2)**2 + np.cos(lat1_rad) * np.cos(lat2_rad) * np.sin(delta_lon/2)**2 + c = 2 * np.arcsin(np.sqrt(a)) + return R * c + +def haversine_km(lon1, lat1, lon2, lat2): + lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2]) + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2 + c = 2 * np.arcsin(np.sqrt(a)) + km = 6371 * c + return km + +def create_circle_points(center_lat, center_lon, radius_m, num_points=50): + R = 6371000 + lats, lons = [], [] + for i in range(num_points + 1): + bearing = 2 * np.pi * i / num_points + lat1_rad = np.radians(center_lat) + lon1_rad = np.radians(center_lon) + angular_distance = radius_m / R + lat2_rad = np.arcsin( + np.sin(lat1_rad) * np.cos(angular_distance) + + np.cos(lat1_rad) * np.sin(angular_distance) * np.cos(bearing) + ) + lon2_rad = lon1_rad + np.arctan2( + np.sin(bearing) * np.sin(angular_distance) * np.cos(lat1_rad), + np.cos(angular_distance) - np.sin(lat1_rad) * np.sin(lat2_rad) + ) + lats.append(np.degrees(lat2_rad)) + lons.append(np.degrees(lon2_rad)) + return lats, lons + +def haversine_distance_vectorized(lat0, lon0, lats, lons): + R = 6371000.0 + lat0r = np.radians(lat0) + lon0r = np.radians(lon0) + latr = np.radians(lats) + lonr = np.radians(lons) + dlat = latr - lat0r + dlon = lonr - lon0r + a = np.sin(dlat/2.0)**2 + np.cos(lat0r) * np.cos(latr) * np.sin(dlon/2.0)**2 + return 2.0 * R * np.arcsin(np.sqrt(a)) diff --git a/src/analysis/grouping.py b/src/analysis/grouping.py new file mode 100644 index 0000000..632e4ae --- /dev/null +++ b/src/analysis/grouping.py @@ -0,0 +1,93 @@ +import pandas as pd +import numpy as np +from typing import List, Tuple, Dict +from src.analysis.geospatial import haversine_distance +from src.utils import get_grouping_radius_m +from src.config import config +import logging + +logger = logging.getLogger(__name__) + +def group_turbines_by_proximity(turbine_df: pd.DataFrame, max_distance_m: int = None) -> List[List[int]]: + """ + Group turbines based on proximity within max_distance_m meters. + Returns list of groups, where each group is a list of turbine indices. + """ + if max_distance_m is None: + max_distance_m = get_grouping_radius_m() + try: + from sklearn.cluster import DBSCAN + import numpy as np + coords = np.radians(turbine_df[['lat', 'lng']].values) + eps_rad = (max_distance_m / 1000.0) / 6371.0 + clustering = DBSCAN(eps=eps_rad, min_samples=1, metric='haversine').fit(coords) + labels = clustering.labels_ + groups = [] + for label in sorted(set(labels)): + idxs = np.where(labels == label)[0].tolist() + groups.append(idxs) + return groups + except Exception: + n_turbines = len(turbine_df) + groups = [] + assigned = set() + for i in range(n_turbines): + if i in assigned: + continue + current_group = [i] + assigned.add(i) + for j in range(i + 1, n_turbines): + if j in assigned: + continue + distance = haversine_distance( + turbine_df.iloc[i]['lat'], turbine_df.iloc[i]['lng'], + turbine_df.iloc[j]['lat'], turbine_df.iloc[j]['lng'] + ) + if distance <= max_distance_m: + current_group.append(j) + assigned.add(j) + groups.append(current_group) + return groups + +def calculate_group_centroid(turbine_df: pd.DataFrame, group_indices: List[int]) -> Tuple[float, float]: + """ + Calculate the centroid (center point) of a group of turbines. + Returns (lat, lng) of the centroid. + """ + if not group_indices: + return 0.0, 0.0 + + lats = turbine_df.iloc[group_indices]['lat'].values + lngs = turbine_df.iloc[group_indices]['lng'].values + + centroid_lat = np.mean(lats) + centroid_lng = np.mean(lngs) + + return centroid_lat, centroid_lng + +def create_turbine_groups(turbine_df: pd.DataFrame) -> Dict: + """ + Create turbine groups and calculate their centroids. + Returns a dictionary with group information. + """ + groups = group_turbines_by_proximity(turbine_df) + + group_data = [] + for i, group_indices in enumerate(groups): + centroid_lat, centroid_lng = calculate_group_centroid(turbine_df, group_indices) + + group_info = { + 'group_id': i, + 'turbine_indices': group_indices, + 'centroid_lat': centroid_lat, + 'centroid_lng': centroid_lng, + 'turbine_count': len(group_indices), + 'is_single': len(group_indices) == 1 + } + group_data.append(group_info) + + return { + 'groups': group_data, + 'total_groups': len(groups), + 'total_turbines': len(turbine_df) + } \ No newline at end of file diff --git a/src/analysis/histogram.py b/src/analysis/histogram.py new file mode 100644 index 0000000..51970a1 --- /dev/null +++ b/src/analysis/histogram.py @@ -0,0 +1,363 @@ +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from collections import defaultdict +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from src.analysis.geospatial import haversine_distance, haversine_distance_vectorized +from src.config import config +from src.utils import get_analysis_radius_m +import logging + +logger = logging.getLogger(__name__) + +def filter_lightning_by_distance(lightning_df, centroid_lat, centroid_lng): + """ + Filter lightning data to only include events within the farthest distance ring. + """ + max_distance = get_analysis_radius_m() + if len(lightning_df) == 0: + return lightning_df.copy() + dists = haversine_distance_vectorized( + centroid_lat, + centroid_lng, + lightning_df['lat'].values, + lightning_df['lng'].values, + ) + mask = dists <= max_distance + return lightning_df.loc[mask].copy() + +def find_activity_periods(df, min_gap_minutes=30, min_events_per_period=10): + """ + Find concentrated activity periods based on time gaps. + """ + df_sorted = df.copy() + if df_sorted['local_time'].dtype == 'object': + df_sorted['local_time'] = pd.to_datetime(df_sorted['local_time']) + df_sorted = df_sorted.sort_values('local_time').reset_index(drop=True) + + df_sorted['time_diff'] = df_sorted['local_time'].diff() + gap = timedelta(minutes=min_gap_minutes) + break_positions = df_sorted.index[df_sorted['time_diff'] > gap].tolist() + + periods = [] + start_pos = 0 + for break_pos in break_positions: + period_slice = df_sorted.iloc[start_pos:break_pos] + if len(period_slice) >= min_events_per_period: + periods.append({ + 'start_time': period_slice['local_time'].iloc[0], + 'end_time': period_slice['local_time'].iloc[-1], + 'data': period_slice, + 'event_count': len(period_slice) + }) + start_pos = break_pos + + last_slice = df_sorted.iloc[start_pos:] + if len(last_slice) >= min_events_per_period: + periods.append({ + 'start_time': last_slice['local_time'].iloc[0], + 'end_time': last_slice['local_time'].iloc[-1], + 'data': last_slice, + 'event_count': len(last_slice) + }) + + return periods + +def create_minute_counts(period_data): + """ + Create minute-by-minute counts of lightning events. + """ + # Ensure local_time is datetime + period_data = period_data.copy() + if period_data['local_time'].dtype == 'object': + period_data['local_time'] = pd.to_datetime(period_data['local_time']) + + # Round timestamps to nearest minute + period_data['minute_rounded'] = period_data['local_time'].dt.floor('min') + + # Count events per minute for each p_type + minute_counts = period_data.groupby(['minute_rounded', 'p_type']).size().unstack(fill_value=0) + + # Create complete minute range + start_minute = period_data['minute_rounded'].min() + end_minute = period_data['minute_rounded'].max() + minute_range = pd.date_range(start=start_minute, end=end_minute, freq='min') + + # Reindex to include all minutes (fill missing with 0) + minute_counts = minute_counts.reindex(minute_range, fill_value=0) + + return minute_counts + +def find_peak_sub_periods(period_data, window_minutes=3): + """ + Find peak sub-periods within an activity period. + """ + minute_counts = create_minute_counts(period_data) + + # Calculate total events per minute + if '0' in minute_counts.columns and '1' in minute_counts.columns: + total_per_minute = minute_counts['0'] + minute_counts['1'] + elif '0' in minute_counts.columns: + total_per_minute = minute_counts['0'] + elif '1' in minute_counts.columns: + total_per_minute = minute_counts['1'] + else: + return [] + + # Use rolling window to find peak periods + rolling_mean = total_per_minute.rolling(window=window_minutes, center=True).mean() + mean_activity = total_per_minute.mean() + std_activity = total_per_minute.std() + + # Find periods above mean + 1 standard deviation + threshold = mean_activity + std_activity + peak_mask = rolling_mean > threshold + + # Group consecutive peak minutes + peak_periods = [] + in_peak = False + start_time = None + + for i, (time, is_peak) in enumerate(zip(total_per_minute.index, peak_mask)): + if is_peak and not in_peak: + start_time = time + in_peak = True + elif not is_peak and in_peak: + end_time = total_per_minute.index[i-1] + peak_periods.append({ + 'start': start_time, + 'end': end_time, + 'peak_rate': rolling_mean[start_time:end_time].max() + }) + in_peak = False + + # Handle case where peak period extends to the end + if in_peak: + peak_periods.append({ + 'start': start_time, + 'end': total_per_minute.index[-1], + 'peak_rate': rolling_mean[start_time:].max() + }) + + return peak_periods + +def _build_histogram_figure_for_periods(periods_chunk, max_distance_km): + """ + Build a Plotly figure for a subset of activity periods. + + Args: + periods_chunk: List of period dictionaries to plot + max_distance_km: Maximum distance in km for the title + + Returns: + Plotly figure object + """ + n_periods = len(periods_chunk) + + if n_periods == 0: + return None + + # Determine layout: single column if <= 3 periods, otherwise 2 columns + if n_periods <= 3: + n_cols = 1 + else: + n_cols = 2 + + n_rows = (n_periods + n_cols - 1) // n_cols + # Cap n_rows at 3 (max 2×3 grid = 6 periods per figure) + n_rows = min(n_rows, 3) + + # Adjust periods if we capped rows + if n_periods > n_rows * n_cols: + periods_chunk = periods_chunk[:n_rows * n_cols] + n_periods = len(periods_chunk) + + # Calculate spacing + base_row_height = 350 + if n_rows > 1: + vertical_spacing = 0.28 # larger gap between rows + else: + vertical_spacing = 0.15 + horizontal_spacing = 0.1 if n_cols == 2 else 0.08 + + def format_period_title(i, p): + start = p['start_time'] + end = p['end_time'] + event_count = p['event_count'] + + # Format date + date_str = start.strftime('%d-%m-%Y') + + # Determine period type + if start.date() == end.date(): + period_str = f"{start.strftime('%H:%M')}-{end.strftime('%H:%M')}" + else: + period_str = f"{start.strftime('%d-%m-%Y %H:%M')}-{end.strftime('%d-%m-%Y %H:%M')}" + + # Use line breaks to fit within histogram width + return f"Date: {date_str}
Period: {period_str}
Total lightnings: {event_count}" + + fig = make_subplots( + rows=n_rows, + cols=n_cols, + subplot_titles=[format_period_title(i, p) for i, p in enumerate(periods_chunk)], + vertical_spacing=vertical_spacing, + horizontal_spacing=horizontal_spacing, + specs=[[{"secondary_y": False} for _ in range(n_cols)] for _ in range(n_rows)] + ) + + colors = {'0': '#FF6B6B', '1': '#4ECDC4'} # Red for cloud-to-ground, Teal for intracloud + + for period_idx, period in enumerate(periods_chunk): + row = (period_idx // n_cols) + 1 + col = (period_idx % n_cols) + 1 + + minute_counts = create_minute_counts(period['data']) + + # Create time labels (minutes from start) + start_time = minute_counts.index[0] + minutes_from_start = [(t - start_time).total_seconds() / 60 for t in minute_counts.index] + + # Add bars for each p_type + for p_type in ['0', '1']: + if p_type in minute_counts.columns: + counts = minute_counts[p_type].values + + p_type_name = "Cloud-to-Ground" if p_type == '0' else "Intercloud" + + fig.add_trace( + go.Bar( + x=minutes_from_start, + y=counts, + name=p_type_name if period_idx == 0 else None, + marker_color=colors[p_type], + opacity=0.7, + showlegend=(period_idx == 0), + legendgroup=f"p_type_{p_type}", + hovertemplate=f"{p_type_name}
Minute: %{{x:.0f}}
Count: %{{y}}" + ), + row=row, col=col + ) + + # Find and highlight peak sub-periods + peak_periods = find_peak_sub_periods(period['data']) + for peak in peak_periods: + peak_start_minutes = (peak['start'] - start_time).total_seconds() / 60 + peak_end_minutes = (peak['end'] - start_time).total_seconds() / 60 + + fig.add_vrect( + x0=peak_start_minutes, x1=peak_end_minutes, + fillcolor="yellow", opacity=0.2, + line_width=0, + row=row, col=col + ) + + # Update axes for this subplot + fig.update_xaxes( + title_text="Minutes from start", + title_standoff=6, + row=row, + col=col, + tickfont=dict(size=18), + title_font=dict(size=22), + ) + # Y-axis labels only on leftmost column + if col == 1: + fig.update_yaxes( + title_text="Lightning Count", + row=row, + col=col, + tickfont=dict(size=18), + title_font=dict(size=22), + ) + else: + fig.update_yaxes( + title_text="", + row=row, + col=col, + tickfont=dict(size=16), + title_font=dict(size=22), + ) + + # Calculate figure height + figure_height = base_row_height * n_rows + + # Update subplot title font size (subplot titles are rendered as annotations) + fig.update_annotations(font=dict(size=22)) + + fig.update_layout( + height=figure_height, + width=config.histogram_layout['plot_width'], + barmode='stack', + showlegend=True, + font=dict(size=18), + margin=dict(t=110, b=100, l=75, r=40), + legend=dict( + orientation="h", + y=-0.1, + x=0.5, + xanchor="center", + font=dict(size=18), + title_font=dict(size=20), + ) + ) + + # Store metadata for render function + fig.layout.meta = {'n_rows': n_rows, 'n_cols': n_cols} + + return fig + +def create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng): + """ + Create multiple histogram figures for pagination when there are many activity periods. + + Args: + lightning_df: DataFrame containing lightning data + centroid_lat: Center latitude + centroid_lng: Center longitude + + Returns: + List of Plotly figure objects (empty list if no periods found) + """ + # Filter lightning data by distance + filtered_df = filter_lightning_by_distance(lightning_df, centroid_lat, centroid_lng) + + if len(filtered_df) == 0: + return [] + + # Find ALL activity periods (no truncation) + periods = find_activity_periods( + filtered_df, + min_gap_minutes=config.histogram_params['min_gap_minutes'], + min_events_per_period=config.histogram_params['min_events_per_period'] + ) + + if len(periods) == 0: + return [] + + # Get max periods per figure from config + max_periods_per_figure = config.histogram_params.get('max_periods_per_figure', 6) + + # Get max distance for title + max_distance_km = max(config.distance_rings) / 1000 + + # Split periods into chunks + figures = [] + for i in range(0, len(periods), max_periods_per_figure): + chunk = periods[i:i + max_periods_per_figure] + fig = _build_histogram_figure_for_periods(chunk, max_distance_km) + if fig: + figures.append(fig) + + return figures + +def create_lightning_histogram(lightning_df, centroid_lat, centroid_lng): + """ + Create lightning activity histogram for the PDF report. + + This is a backward-compatible wrapper that returns the first page. + For multi-page support, use create_lightning_histogram_pages instead. + """ + pages = create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng) + return pages[0] if pages else None \ No newline at end of file diff --git a/src/analysis/risk.py b/src/analysis/risk.py new file mode 100644 index 0000000..0521f92 --- /dev/null +++ b/src/analysis/risk.py @@ -0,0 +1,153 @@ +import numpy as np +import pandas as pd +from typing import Tuple +from .geospatial import haversine_distance +from ..config import config +import logging + +logger = logging.getLogger(__name__) + +def calculate_distance_matrix(turbine_coords: np.ndarray, lightning_coords: np.ndarray) -> np.ndarray: + """ + Calculate distance matrix between turbines and lightning strikes using vectorized operations. + + Args: + turbine_coords: Array of turbine coordinates (lat, lng) + lightning_coords: Array of lightning coordinates (lat, lng) + + Returns: + Distance matrix with shape (n_turbines, n_lightning) + """ + # Extract coordinates + turbine_lats = turbine_coords[:, 0] + turbine_lons = turbine_coords[:, 1] + lightning_lats = lightning_coords[:, 0] + lightning_lons = lightning_coords[:, 1] + + # Use broadcasting to calculate all distances at once + # This is much more efficient than nested loops + lat_diff = lightning_lats[:, np.newaxis] - turbine_lats[np.newaxis, :] + lon_diff = lightning_lons[:, np.newaxis] - turbine_lons[np.newaxis, :] + + # Haversine formula components + lat_diff_rad = np.radians(lat_diff) + lon_diff_rad = np.radians(lon_diff) + turbine_lats_rad = np.radians(turbine_lats)[np.newaxis, :] + lightning_lats_rad = np.radians(lightning_lats)[:, np.newaxis] + + a = (np.sin(lat_diff_rad/2)**2 + + np.cos(turbine_lats_rad) * np.cos(lightning_lats_rad) * np.sin(lon_diff_rad/2)**2) + c = 2 * np.arcsin(np.sqrt(a)) + + # Convert to kilometers + distances_km = 6371 * c + + return distances_km.T # Transpose to get (n_turbines, n_lightning) + +def calculate_turbine_risks_vectorized(turbine_df: pd.DataFrame, lightning_df: pd.DataFrame) -> pd.DataFrame: + """ + Calculate turbine risks using vectorized operations for better performance. + + Args: + turbine_df: DataFrame containing turbine data + lightning_df: DataFrame containing lightning data + + Returns: + DataFrame with added risk columns + """ + logger.info("Starting vectorized risk calculation...") + + # Filter for cloud-to-ground lightning only + cg_lightning_df = lightning_df[lightning_df['p_type'].astype(str) == '0'].copy() + + if len(cg_lightning_df) == 0: + logger.warning("No cloud-to-ground lightning found") + turbine_df = turbine_df.copy() + turbine_df['risk_score'] = 0.0 + turbine_df['risk_log'] = 0.0 + return turbine_df + + # Extract coordinates as numpy arrays + turbine_coords = turbine_df[['lat', 'lng']].values + lightning_coords = cg_lightning_df[['lat', 'lng']].values + + # Calculate distance matrix + logger.info(f"Calculating distance matrix for {len(turbine_df)} turbines and {len(cg_lightning_df)} lightning strikes...") + distance_matrix = calculate_distance_matrix(turbine_coords, lightning_coords) + + # Get risk parameters + P_0 = config.risk_params['P_0'] + alpha = config.risk_params['alpha'] + current_weight = config.risk_params['current_weight'] + + # Calculate current magnitudes + current_magnitudes = np.abs(cg_lightning_df['current'].values) + + # Calculate risk matrix using vectorized operations + # Shape: (n_turbines, n_lightning) + current_factor = 1 + current_weight * current_magnitudes / 10000 + distance_factor = np.exp(-alpha * distance_matrix) + risk_matrix = P_0 * current_factor[np.newaxis, :] * distance_factor + + # Sum risks for each turbine + turbine_risks = np.sum(risk_matrix, axis=1) + + # Add risk columns to DataFrame + turbine_df = turbine_df.copy() + turbine_df['risk_score'] = turbine_risks + turbine_df['risk_log'] = np.log10(turbine_risks + 1) + + logger.info(f"Risk calculation completed. Risk range: {turbine_risks.min():.2f} - {turbine_risks.max():.2f}") + + return turbine_df + +def calculate_turbine_risks(turbine_df: pd.DataFrame, lightning_df: pd.DataFrame) -> pd.DataFrame: + """ + Calculate turbine risks (legacy function for backward compatibility). + Now uses vectorized implementation for better performance. + """ + try: + from sklearn.neighbors import BallTree + logger.info("Starting BallTree-based risk calculation...") + cg_lightning_df = lightning_df[lightning_df['p_type'].astype(str) == '0'].copy() + if len(cg_lightning_df) == 0: + result_df = turbine_df.copy() + result_df['risk_score'] = 0.0 + result_df['risk_log'] = 0.0 + return result_df + + import numpy as np + earth_km = 6371.0 + max_radius_km = max(config.distance_rings) / 1000.0 + radius_rad = max_radius_km / earth_km + + lightning_coords_rad = np.radians(cg_lightning_df[['lat', 'lng']].values) + tree = BallTree(lightning_coords_rad, metric='haversine') + + turbine_coords_rad = np.radians(turbine_df[['lat', 'lng']].values) + ind_list, dist_list = tree.query_radius(turbine_coords_rad, r=radius_rad, return_distance=True, sort_results=True) + + P_0 = config.risk_params['P_0'] + alpha = config.risk_params['alpha'] + current_weight = config.risk_params['current_weight'] + currents = np.abs(cg_lightning_df['current'].values) + + risk_scores = np.zeros(len(turbine_df), dtype=float) + for i in range(len(turbine_df)): + idxs = ind_list[i] + if idxs.size == 0: + continue + dists_km = dist_list[i] * earth_km + current_mag = currents[idxs] + current_factor = 1.0 + current_weight * current_mag / 10000.0 + distance_factor = np.exp(-alpha * dists_km) + risk_scores[i] = np.sum(P_0 * current_factor * distance_factor) + + result_df = turbine_df.copy() + result_df['risk_score'] = risk_scores + result_df['risk_log'] = np.log10(risk_scores + 1.0) + logger.info("BallTree-based risk calculation completed") + return result_df + except Exception as e: + logger.warning(f"Falling back to vectorized matrix risk calculation due to: {e}") + return calculate_turbine_risks_vectorized(turbine_df, lightning_df) diff --git a/src/analysis/statistics.py b/src/analysis/statistics.py new file mode 100644 index 0000000..12d774b --- /dev/null +++ b/src/analysis/statistics.py @@ -0,0 +1,208 @@ +import pandas as pd +import numpy as np +from datetime import datetime +from src.analysis.geospatial import haversine_distance, haversine_distance_vectorized +from src.config import config +from src.utils import get_analysis_radius_m, parse_period_string_to_datetime +import logging + +logger = logging.getLogger(__name__) + +def calculate_lightning_statistics(lightning_df, centroid_lat, centroid_lng, start_date=None, end_date=None): + """ + Calculate lightning statistics for the report with detailed breakdown by distance rings. + + Args: + lightning_df: DataFrame containing lightning data + centroid_lat: Center latitude + centroid_lng: Center longitude + start_date: Start date in 'DD-MM-YYYY' format (optional) + end_date: End date in 'DD-MM-YYYY' format (optional) + """ + max_distance = get_analysis_radius_m() + + # Filter lightning data by distance (vectorized) + if len(lightning_df) == 0: + return { + 'intercloud_by_day': {}, + 'cloud_to_ground_by_day': {}, + 'total_lightning_per_km2': 0, + 'daily_lightning_per_km2': 0, + 'period_days': 0.0, + 'total_events': 0, + 'area_km2': 0, + 'max_distance_km': max_distance / 1000, + 'lightning_by_distance_rings': {}, + 'daily_lightning_by_rings': {} + } + + dists_all = haversine_distance_vectorized( + centroid_lat, + centroid_lng, + lightning_df['lat'].values, + lightning_df['lng'].values, + ) + mask = dists_all <= max_distance + filtered_df = lightning_df.loc[mask].copy() + + if len(filtered_df) == 0: + return { + 'intercloud_by_day': {}, + 'cloud_to_ground_by_day': {}, + 'total_lightning_per_km2': 0, + 'daily_lightning_per_km2': 0, + 'period_days': 0.0, + 'total_events': 0, + 'area_km2': 0, + 'max_distance_km': max_distance / 1000, + 'lightning_by_distance_rings': {}, + 'daily_lightning_by_rings': {} + } + + # Convert local_time to datetime if needed + if filtered_df['local_time'].dtype == 'object': + filtered_df['local_time'] = pd.to_datetime(filtered_df['local_time']) + + # Add distance column + filtered_df['distance_km'] = (dists_all[mask] / 1000) + + # Add date column + filtered_df['date'] = filtered_df['local_time'].dt.date + + # 1. Inter-cloud lightnings by day (outermost ring only) + intercloud_df = filtered_df[filtered_df['p_type'].astype(str) == '1'].copy() + intercloud_by_day = {} + if len(intercloud_df) > 0: + intercloud_by_day = intercloud_df['date'].value_counts().to_dict() + # Convert date objects to strings for JSON serialization + intercloud_by_day = {date.strftime("%d-%m-%Y"): count for date, count in intercloud_by_day.items()} + + # 2. Cloud-to-ground lightnings by day (outermost ring only) + cloud_to_ground_df = filtered_df[filtered_df['p_type'].astype(str) == '0'].copy() + cloud_to_ground_by_day = {} + if len(cloud_to_ground_df) > 0: + cloud_to_ground_by_day = cloud_to_ground_df['date'].value_counts().to_dict() + # Convert date objects to strings for JSON serialization + cloud_to_ground_by_day = {date.strftime("%d-%m-%Y"): count for date, count in cloud_to_ground_by_day.items()} + + # 3. Lightning counts by distance rings (ranges instead of cumulative) + lightning_by_distance_rings = {} + for i, ring_distance in enumerate(config.distance_rings): + ring_km = ring_distance / 1000 + + # Define distance range for this ring + if i == 0: + # First ring: 0 to ring_km + min_distance = 0 + ring_max_distance = ring_km + ring_name = f"0-{ring_km:.1f}km" + else: + # Other rings: previous ring to current ring + prev_ring_km = config.distance_rings[i-1] / 1000 + min_distance = prev_ring_km + ring_max_distance = ring_km + ring_name = f"{prev_ring_km:.1f}-{ring_km:.1f}km" + + # Count lightning within this distance range + ring_lightning = filtered_df[ + (filtered_df['distance_km'] > min_distance) & + (filtered_df['distance_km'] <= ring_max_distance) + ] + + # Count by type + intercloud_count = len(ring_lightning[ring_lightning['p_type'].astype(str) == '1']) + cloud_to_ground_count = len(ring_lightning[ring_lightning['p_type'].astype(str) == '0']) + total_count = len(ring_lightning) + + lightning_by_distance_rings[ring_name] = { + 'intercloud': intercloud_count, + 'cloud_to_ground': cloud_to_ground_count, + 'total': total_count + } + + # 4. Daily lightning breakdown by distance rings + daily_lightning_by_rings = {} + + # Get unique dates + unique_dates = sorted(filtered_df['date'].unique()) + + for date in unique_dates: + date_str = date.strftime("%d-%m-%Y") + daily_lightning_by_rings[date_str] = {} + + # Get lightning for this date + daily_lightning = filtered_df[filtered_df['date'] == date] + + for i, ring_distance in enumerate(config.distance_rings): + ring_km = ring_distance / 1000 + + # Define distance range for this ring + if i == 0: + # First ring: 0 to ring_km + min_distance = 0 + ring_max_distance = ring_km + ring_name = f"0-{ring_km:.1f}km" + else: + # Other rings: previous ring to current ring + prev_ring_km = config.distance_rings[i-1] / 1000 + min_distance = prev_ring_km + ring_max_distance = ring_km + ring_name = f"{prev_ring_km:.1f}-{ring_km:.1f}km" + + # Count lightning within this distance range for this date + ring_lightning = daily_lightning[ + (daily_lightning['distance_km'] > min_distance) & + (daily_lightning['distance_km'] <= ring_max_distance) + ] + + # Count by type + intercloud_count = len(ring_lightning[ring_lightning['p_type'].astype(str) == '1']) + cloud_to_ground_count = len(ring_lightning[ring_lightning['p_type'].astype(str) == '0']) + total_count = len(ring_lightning) + + daily_lightning_by_rings[date_str][ring_name] = { + 'intercloud': intercloud_count, + 'cloud_to_ground': cloud_to_ground_count, + 'total': total_count + } + + # 5. Calculate area and lightning density (outermost ring) + area_km2 = np.pi * (max_distance / 1000) ** 2 + total_events = len(filtered_df) + total_lightning_per_km2 = total_events / area_km2 if area_km2 > 0 else 0 + + if start_date and end_date: + start_dt = parse_period_string_to_datetime(start_date) + end_dt = parse_period_string_to_datetime(end_date) + if start_dt is not None and end_dt is not None: + delta = end_dt - start_dt + period_seconds = delta.total_seconds() + period_days = period_seconds / 86400.0 if period_seconds > 0 else 1.0 + logger.info(f"Analysis period duration: {period_days:.4f} days ({start_date} to {end_date})") + else: + period_days = 1.0 + if len(filtered_df) > 0: + first_date = filtered_df['local_time'].min() + period_days = float(pd.Timestamp(first_date.year, first_date.month, 1).days_in_month) + logger.warning(f"Failed to parse date range ({start_date}, {end_date}). Using period_days={period_days}") + elif len(filtered_df) > 0: + first_date = filtered_df['local_time'].min() + period_days = float(pd.Timestamp(first_date.year, first_date.month, 1).days_in_month) + logger.info(f"Using month as period: {period_days} days") + else: + period_days = 1.0 + + daily_lightning_per_km2 = total_lightning_per_km2 / period_days if period_days > 0 else 0 + + return { + 'intercloud_by_day': intercloud_by_day, + 'cloud_to_ground_by_day': cloud_to_ground_by_day, + 'total_lightning_per_km2': total_lightning_per_km2, + 'daily_lightning_per_km2': daily_lightning_per_km2, + 'period_days': period_days, + 'total_events': total_events, + 'area_km2': area_km2, + 'max_distance_km': max_distance / 1000, + 'lightning_by_distance_rings': lightning_by_distance_rings, + 'daily_lightning_by_rings': daily_lightning_by_rings + } \ No newline at end of file diff --git a/src/api/data_fetcher.py b/src/api/data_fetcher.py new file mode 100644 index 0000000..4590443 --- /dev/null +++ b/src/api/data_fetcher.py @@ -0,0 +1,275 @@ +import os +import requests +import json +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple +import logging +from dotenv import load_dotenv +import pandas as pd +import re + +load_dotenv() + +logger = logging.getLogger(__name__) + +class APIDataFetcher: + def __init__(self, base_url: str, timeout: int = 30, retry_attempts: int = 3): + self.base_url = base_url.rstrip('/') + self.api_key = os.getenv('API_KEY') + self.timeout = timeout + self.retry_attempts = retry_attempts + + if not self.api_key: + raise ValueError("API_KEY not found in .env file") + + self.session = requests.Session() + self.session.headers.update({ + 'Content-Type': 'application/json', + 'x-api-key': self.api_key + }) + + def _make_request(self, endpoint: str, params: Dict) -> Dict: + """Make API request with retry logic.""" + url = f"{self.base_url}{endpoint}" + + # API key is already in headers (x-api-key) + for attempt in range(self.retry_attempts): + try: + response = self.session.get(url, params=params, timeout=self.timeout) + response.raise_for_status() + return response.json() + except requests.exceptions.Timeout: + logger.warning(f"API request timeout (attempt {attempt + 1}/{self.retry_attempts})") + if attempt == self.retry_attempts - 1: + raise + except requests.exceptions.RequestException as e: + logger.error(f"API request failed: {e}") + if attempt == self.retry_attempts - 1: + raise + except json.JSONDecodeError as e: + logger.error(f"Failed to parse API response: {e}") + raise + + raise Exception("API request failed after all retries") + + def fetch_lightning_data( + self, + center_lat: float, + center_lng: float, + radius_km: float, + start_date: str, + end_date: str + ) -> List[Dict]: + """ + Fetch lightning data from API. + + Args: + center_lat: Center latitude + center_lng: Center longitude + radius_km: Radius in kilometers (converted to meters for API) + start_date: Start date in YYYY-MM-DD format + end_date: End date in YYYY-MM-DD format + + Returns: + List of lightning records + """ + endpoint = "/lightning-data/historical/" + + radius_m = int(radius_km * 1000) + + params = { + 'queryType': 'circle', + 'centerLongitude': center_lng, + 'centerLatitude': center_lat, + 'radius': radius_m, + 'startDate': start_date, + 'endDate': end_date + } + + logger.info(f"Fetching lightning data: center=({center_lat}, {center_lng}), radius={radius_km}km, dates={start_date} to {end_date}") + + try: + data = self._make_request(endpoint, params) + + if isinstance(data, dict) and 'data' in data: + records = data['data'] + elif isinstance(data, list): + records = data + else: + records = [] + + logger.info(f"Fetched {len(records)} lightning records") + return records + + except Exception as e: + logger.error(f"Failed to fetch lightning data: {e}") + raise + + def fetch_storm_data( + self, + center_lat: float, + center_lng: float, + radius_km: float, + start_date: str, + end_date: str + ) -> List[Dict]: + """ + Fetch storm data from API. + + Args: + center_lat: Center latitude + center_lng: Center longitude + radius_km: Radius in kilometers (converted to meters for API) + start_date: Start date in YYYY-MM-DD format + end_date: End date in YYYY-MM-DD format + + Returns: + List of storm records + """ + endpoint = "/storm-data/historical/" + + radius_m = int(radius_km * 1000) + + params = { + 'queryType': 'circle', + 'centerLongitude': center_lng, + 'centerLatitude': center_lat, + 'radius': radius_m, + 'startDate': start_date, + 'endDate': end_date + } + + logger.info(f"Fetching storm data: center=({center_lat}, {center_lng}), radius={radius_km}km, dates={start_date} to {end_date}") + + try: + data = self._make_request(endpoint, params) + + if isinstance(data, dict) and 'data' in data: + records = data['data'] + elif isinstance(data, list): + records = data + else: + records = [] + + logger.info(f"Fetched {len(records)} storm records") + return records + + except Exception as e: + logger.error(f"Failed to fetch storm data: {e}") + raise + + def calculate_location_bounds( + self, + turbine_df: pd.DataFrame, + max_distance_ring_m: int, + padding_km: float = 5 + ) -> Dict[str, float]: + """ + Auto-calculate center + radius from turbine coordinates. + + Args: + turbine_df: DataFrame with 'lat' and 'lng' columns + max_distance_ring_m: Maximum distance ring in meters + padding_km: Additional padding in kilometers + + Returns: + Dict with center_lat, center_lng, radius_km + """ + from src.analysis.geospatial import haversine_distance + + centroid_lat = turbine_df['lat'].mean() + centroid_lng = turbine_df['lng'].mean() + + max_distance_from_centroid = 0 + for _, turbine in turbine_df.iterrows(): + distance = haversine_distance( + centroid_lat, centroid_lng, + turbine['lat'], turbine['lng'] + ) + max_distance_from_centroid = max(max_distance_from_centroid, distance) + + radius_km = (max_distance_from_centroid / 1000) + \ + (max_distance_ring_m / 1000) + \ + padding_km + + return { + "center_lat": centroid_lat, + "center_lng": centroid_lng, + "radius_km": radius_km + } + + @staticmethod + def determine_query_date_range( + farm_config: Dict, + api_config: Dict + ) -> Tuple[datetime, datetime]: + """ + Determine date range for API query. + + Args: + farm_config: Farm configuration dict + api_config: API configuration dict + + Returns: + Tuple of (start_date, end_date) as datetime objects + """ + date_config = farm_config['api_params']['date_range'] + + def _parse_config_date(value: str) -> datetime: + value_str = str(value).strip() + if not value_str: + raise ValueError("Empty date value") + + if re.fullmatch(r"\d{2}-\d{2}-\d{4}", value_str): + return datetime.strptime(value_str, '%d-%m-%Y') + + ts = pd.to_datetime(value_str, errors='raise') + if isinstance(ts, pd.Timestamp): + if ts.tzinfo is not None: + ts = ts.tz_convert('UTC').tz_localize(None) + return ts.to_pydatetime() + + raise ValueError(f"Unsupported date value: {value_str}") + + if date_config['method'] == 'manual': + start_date = _parse_config_date(date_config['start_date']) + end_date = _parse_config_date(date_config['end_date']) + return start_date, end_date + + today = datetime.now() + query_range = date_config.get('query_range', {}) + method = query_range.get('method', api_config.get('default_query_range', {}).get('method', 'current_month')) + + if method == 'current_month': + start_date = today.replace(day=1) + end_date = today + + elif method == 'last_month': + if today.month == 1: + start_date = today.replace(year=today.year-1, month=12, day=1) + else: + start_date = today.replace(month=today.month-1, day=1) + + if today.month == 1: + last_day = (today.replace(year=today.year-1, month=12, day=28) + + timedelta(days=4)).replace(day=1) - timedelta(days=1) + else: + last_day = (today.replace(month=today.month, day=28) + + timedelta(days=4)).replace(day=1) - timedelta(days=1) + end_date = last_day + + elif method == 'days_back': + days = query_range.get('days', 30) + start_date = today - timedelta(days=days) + end_date = today + + elif method == 'custom': + start_date = _parse_config_date(query_range['start_date']) + end_date = _parse_config_date(query_range['end_date']) + + else: + start_date = today.replace(day=1) + end_date = today + + return start_date, end_date + diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..52de186 --- /dev/null +++ b/src/config.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from typing import List, Dict, Any, Optional +import os + + +@dataclass +class Config: + """ + Centralized configuration for global/default settings. + + IMPORTANT: Farm-specific settings (distance_rings, ring_colors, wind_farm_name, + file paths, date ranges) are managed in wind_farms_config.json and set dynamically + by batch_generate.py. The values below are only fallback defaults for backward + compatibility and should NOT be configured here. + """ + + # Farm-specific settings (set by batch processing, DO NOT configure here) + distance_rings: List[int] = None + ring_colors: List[str] = None + wind_farm_name: str = None + analysis_start_date: Optional[str] = None + analysis_end_date: Optional[str] = None + timezone: Optional[str] = None + + # Lightning data source configuration + # By default, lightning data is expected from API (JSON output). + # When lightning_source_type is set to "csv", lightning_csv should + # point to a CSV file that can be loaded instead. + lightning_source_type: str = "api" + lightning_json: Optional[str] = None + lightning_csv: Optional[str] = None + + # Risk calculation parameters (global defaults) + risk_params: Dict[str, float] = None + + # Histogram parameters (global defaults) + histogram_params: Dict[str, Any] = None + + # Grouping parameters (global defaults, can be overridden per-farm) + grouping_params: Dict[str, Any] = None + + # Centralized histogram layout configuration (global defaults) + histogram_layout: Dict[str, Any] = None + + def __post_init__(self): + """Set default values for global settings.""" + # Fallback defaults for farm-specific settings (only used if not set by batch processing) + # DO NOT configure these - they come from wind_farms_config.json + if self.distance_rings is None: + self.distance_rings = [1000, 2000, 3000, 4000, 10000] + + if self.ring_colors is None: + self.ring_colors = ['purple', 'red', 'orange', 'coral', 'green'] + + if self.risk_params is None: + self.risk_params = { + 'P_0': 1.0, + 'alpha': 0.5, + 'current_weight': 0.1 + } + + if self.histogram_params is None: + self.histogram_params = { + 'min_gap_minutes': 30, + 'min_events_per_period': 10, + 'max_periods': 8, + 'max_periods_per_figure': 6, + 'height_per_row': 200, + 'width': 800 + } + + if self.histogram_layout is None: + self.histogram_layout = { + 'plot_width': 800, + 'plot_height_per_row': 250, + 'pdf_width_ratio': 0.95, + 'pdf_aspect_ratio': 0.75, + 'pdf_top_margin': 140, + 'pdf_left_margin': 40, + 'image_scale': 2, + 'image_engine': 'kaleido' + } + + if self.grouping_params is None: + self.grouping_params = { + 'max_distance_m': None, + 'distance_ring_index': 4, + 'min_group_size': 1, + 'max_group_size': 50 + } +# Global configuration instance +config = Config() \ No newline at end of file diff --git a/src/data/loader.py b/src/data/loader.py new file mode 100644 index 0000000..50c9b67 --- /dev/null +++ b/src/data/loader.py @@ -0,0 +1,205 @@ +import logging +from typing import Dict, Any + +import pandas as pd + +from ..config import config +from ..utils import ( + ensure_datetime_column, + filter_lightning_data_by_date_range, + load_json_data, + normalize_local_time_to_timezone, + validate_lightning_data, + validate_turbine_data, +) + +logger = logging.getLogger(__name__) + + +def _rename_columns_for_lightning(df: pd.DataFrame) -> pd.DataFrame: + required = { + "lat": ["lat", "latitude"], + "lng": ["lng", "lon", "longitude", "long"], + "current": ["current", "amplitude", "amp", "peak_current"], + "p_type": ["p_type", "ptype", "type", "flash_type"], + "local_time": ["local_time", "time", "time_utc", "timestamp", "datetime", "date_time", "localtime"], + } + + columns_lower = {col.lower(): col for col in df.columns} + rename_map: Dict[str, str] = {} + + for target, candidates in required.items(): + found_source = None + for candidate in candidates: + source = columns_lower.get(candidate.lower()) + if source is not None: + found_source = source + break + if found_source is not None and found_source != target: + rename_map[found_source] = target + + if rename_map: + df = df.rename(columns=rename_map) + + missing = [col for col in ["lat", "lng", "current", "p_type", "local_time"] if col not in df.columns] + if missing: + raise ValueError(f"Missing required lightning columns in CSV: {missing}") + + return df + + +def load_lightning_data_from_csv(csv_path: str | None = None) -> pd.DataFrame: + """ + Load and validate lightning data from a CSV file. + + Args: + csv_path: Path to lightning CSV file. If None, uses config.lightning_csv. + + Returns: + DataFrame containing lightning data + """ + if csv_path is None: + csv_path = getattr(config, "lightning_csv", None) + if csv_path is None: + raise ValueError("lightning_csv path must be provided (not available in config)") + + logger.info(f"Loading lightning data from CSV {csv_path}") + + try: + df = pd.read_csv(csv_path) + + if len(df) == 0: + logger.warning( + "No lightning records found in CSV - creating empty DataFrame with required columns", + ) + df = pd.DataFrame(columns=["lat", "lng", "current", "p_type", "local_time"]) + else: + df = _rename_columns_for_lightning(df) + + if not validate_lightning_data(df): + raise ValueError("Lightning data validation failed for CSV source") + + df = ensure_datetime_column(df, "local_time") + + if "current_abs" not in df.columns and len(df) > 0: + df["current_abs"] = df["current"].abs() + elif "current_abs" not in df.columns and len(df) == 0: + df["current_abs"] = pd.Series(dtype="float64") + + start_date = getattr(config, "analysis_start_date", None) + end_date = getattr(config, "analysis_end_date", None) + + if start_date and end_date: + df = filter_lightning_data_by_date_range(df, start_date, end_date) + + farm_tz = getattr(config, "timezone", None) + if len(df) > 0 and farm_tz: + df = normalize_local_time_to_timezone(df, "local_time", farm_tz) + + logger.info(f"Successfully loaded {len(df)} lightning records from CSV") + return df + + except Exception as e: + logger.error(f"Failed to load lightning data from CSV: {e}") + raise + + +def load_lightning_data(json_path: str = None) -> pd.DataFrame: + """ + Load and validate lightning data from configured source. + + If config.lightning_source_type == \"csv\" and no explicit json_path is provided, + data will be loaded from CSV. Otherwise, API/JSON loading is used. + """ + if json_path is None: + source_type = getattr(config, "lightning_source_type", "api") + if source_type == "csv": + return load_lightning_data_from_csv() + + json_path = getattr(config, "lightning_json", None) + if json_path is None: + raise ValueError("lightning_json path must be provided (not available in config)") + + logger.info(f"Loading lightning data from JSON {json_path}") + + try: + data = load_json_data(json_path) + + if isinstance(data, dict) and len(data) > 0: + if "data" in data and isinstance(data["data"], list): + records = data["data"] + else: + records = list(data.values())[0] + else: + records = data + + df = pd.DataFrame(records) + + if len(df) == 0: + logger.warning("No lightning records found in data - creating empty DataFrame with required columns") + df = pd.DataFrame(columns=["lat", "lng", "current", "p_type", "local_time"]) + + if not validate_lightning_data(df): + raise ValueError("Lightning data validation failed") + + df = ensure_datetime_column(df, "local_time") + + if "current_abs" not in df.columns and len(df) > 0: + df["current_abs"] = df["current"].abs() + elif "current_abs" not in df.columns and len(df) == 0: + df["current_abs"] = pd.Series(dtype="float64") + + start_date = getattr(config, "analysis_start_date", None) + end_date = getattr(config, "analysis_end_date", None) + + if start_date and end_date: + df = filter_lightning_data_by_date_range(df, start_date, end_date) + + farm_tz = getattr(config, "timezone", None) + if len(df) > 0 and farm_tz: + df = normalize_local_time_to_timezone(df, "local_time", farm_tz) + + logger.info(f"Successfully loaded {len(df)} lightning records") + return df + + except Exception as e: + logger.error(f"Failed to load lightning data: {e}") + raise + +def load_turbine_data(json_path: str = None) -> pd.DataFrame: + """ + Load and validate turbine data from JSON file. + + Args: + json_path: Path to turbine JSON file. If None, uses config default. + + Returns: + DataFrame containing turbine data + + Raises: + ValueError: If data validation fails + """ + if json_path is None: + json_path = getattr(config, 'turbine_json', None) + if json_path is None: + raise ValueError("turbine_json path must be provided (not available in config)") + + logger.info(f"Loading turbine data from {json_path}") + + try: + # Load JSON data + data = load_json_data(json_path) + + # Create DataFrame + df = pd.DataFrame(data) + + # Validate data + if not validate_turbine_data(df): + raise ValueError("Turbine data validation failed") + + logger.info(f"Successfully loaded {len(df)} turbine records") + return df + + except Exception as e: + logger.error(f"Failed to load turbine data: {e}") + raise diff --git a/src/reporting/docx.py b/src/reporting/docx.py new file mode 100644 index 0000000..418a7cd --- /dev/null +++ b/src/reporting/docx.py @@ -0,0 +1,1114 @@ +from __future__ import annotations + +import io +import os +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Iterable + +import pandas as pd +import matplotlib.pyplot as plt +from docx import Document +from docx.enum.section import WD_ORIENT +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Inches, Pt, RGBColor + +from src.analysis.grouping import create_turbine_groups +from src.analysis.histogram import create_lightning_histogram_pages +from src.analysis.risk import calculate_turbine_risks +from src.analysis.statistics import calculate_lightning_statistics +from src.config import config +from src.reporting.precompute import precompute_group_distances_and_rings +from src.reporting.docx_sections import ( + _build_current_vs_distance_chart, + build_group_lightning_table_data, + build_risk_table_data, +) +from src.utils import ( + format_datetime_ddmmyyyy_hhmm, + format_datetime_to_local_display, + get_utc_offset_label, + now_in_timezone, + get_analysis_radius_m, +) +from src.analysis.geospatial import haversine_distance +from src.visualization.maps import plot_cloud_to_ground_coordinate_plane, plot_intercloud_coordinate_plane +from src.visualization.storm_cells import ( + create_storm_cells_map, + create_storm_cells_summary, + calculate_storm_cell_centroid, + filter_storm_data_by_date_range, + filter_storm_data_by_turbine_proximity, + load_storm_data_from_json, +) +from src.reporting.gemini_commentary import generate_gemini_paragraph +from src.utils import get_risk_definition_by_fixed_intervals + + +@dataclass(frozen=True) +class DocxPageConfig: + left_margin_cm: float = 2.0 + right_margin_cm: float = 2.0 + top_margin_cm: float = 3.0 + bottom_margin_cm: float = 2.0 + page_width_cm: float = 21.0 + page_height_cm: float = 29.7 + + +def _resolve_logo_path(filename: str) -> str: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, "..", "..", "logo", filename) + + +def _set_document_page_setup(doc: Document, cfg: DocxPageConfig) -> None: + section = doc.sections[0] + section.orientation = WD_ORIENT.PORTRAIT + section.page_width = Cm(cfg.page_width_cm) + section.page_height = Cm(cfg.page_height_cm) + section.left_margin = Cm(cfg.left_margin_cm) + section.right_margin = Cm(cfg.right_margin_cm) + section.top_margin = Cm(cfg.top_margin_cm) + section.bottom_margin = Cm(cfg.bottom_margin_cm) + + +def _content_width_inches(doc: Document) -> float: + section = doc.sections[0] + return section.page_width.inches - section.left_margin.inches - section.right_margin.inches + + +def _add_header_logo(doc: Document) -> None: + logo_path = _resolve_logo_path("iklim.png") + if not os.path.isfile(logo_path): + return + section = doc.sections[0] + header = section.header + if header.is_linked_to_previous: + header.is_linked_to_previous = False + paragraph = header.paragraphs[0] if header.paragraphs else header.add_paragraph() + paragraph.text = "" + run = paragraph.add_run() + run.add_picture(logo_path, width=Inches(1.2)) + paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT + + +def _add_footer_page_number_left(doc: Document) -> None: + section = doc.sections[0] + section.different_first_page_header_footer = True + + footer = section.footer + if footer.is_linked_to_previous: + footer.is_linked_to_previous = False + + first_footer = section.first_page_footer + if first_footer.is_linked_to_previous: + first_footer.is_linked_to_previous = False + for p in first_footer.paragraphs: + p.text = "" + + p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.LEFT + p.text = "" + run = p.add_run() + + fld_begin = OxmlElement("w:fldChar") + fld_begin.set(qn("w:fldCharType"), "begin") + + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = " PAGE " + + fld_sep = OxmlElement("w:fldChar") + fld_sep.set(qn("w:fldCharType"), "separate") + + fld_end = OxmlElement("w:fldChar") + fld_end.set(qn("w:fldCharType"), "end") + + run._r.append(fld_begin) + run._r.append(instr) + run._r.append(fld_sep) + run._r.append(fld_end) + + +def _add_cover_logo_centered(doc: Document) -> None: + logo_path = _resolve_logo_path("iklim.png") + if not os.path.isfile(logo_path): + return + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.paragraph_format.space_before = Pt(150) + run = p.add_run() + run.add_picture(logo_path, width=Inches(3.2)) + + +def _add_title(doc: Document, text: str, size_pt: int = 20, bold: bool = True, align: WD_ALIGN_PARAGRAPH = WD_ALIGN_PARAGRAPH.LEFT) -> None: + p = doc.add_paragraph() + p.alignment = align + r = p.add_run(text) + r.bold = bold + r.font.size = Pt(size_pt) + + +def _add_paragraph(doc: Document, text: str, size_pt: int = 11, align: WD_ALIGN_PARAGRAPH = WD_ALIGN_PARAGRAPH.LEFT, bold: bool = False) -> None: + p = doc.add_paragraph() + p.alignment = align + r = p.add_run(text) + r.bold = bold + r.font.size = Pt(size_pt) + + +def _add_bullets(doc: Document, items: Iterable[str], size_pt: int = 10) -> None: + for item in items: + p = doc.add_paragraph(style="List Bullet") + r = p.add_run(str(item)) + r.font.size = Pt(size_pt) + + +def _lighten_hex_color(hex_color: str, factor: float = 0.6) -> str: + s = hex_color.strip() + if s.startswith("#"): + s = s[1:] + if len(s) != 6: + return hex_color + try: + r = int(s[0:2], 16) + g = int(s[2:4], 16) + b = int(s[4:6], 16) + except ValueError: + return hex_color + f = max(0.0, min(1.0, factor)) + r_l = int(r + (255 - r) * f) + g_l = int(g + (255 - g) * f) + b_l = int(b + (255 - b) * f) + return f"{r_l:02X}{g_l:02X}{b_l:02X}" + + +def _shade_paragraph(p, hex_color: str) -> None: + hex_color = _lighten_hex_color(hex_color, factor=0.6) + p_pr = p._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), hex_color) + p_pr.append(shd) + + +def _add_ring_bullet(doc: Document, ring_label: str, ring_color: str | None, size_pt: int = 10) -> None: + p = doc.add_paragraph(style="List Bullet") + if ring_color: + hex_color = _color_to_hex(ring_color) + if hex_color: + _shade_paragraph(p, hex_color) + r = p.add_run(str(ring_label)) + r.font.size = Pt(size_pt) + + +def _ring_color_for_label(label: str) -> str | None: + s = str(label) + if ":" in s: + s = s.split(":", 1)[0] + s = s.strip().lower().replace("km", "") + if "-" not in s: + return None + parts = s.split("-", 1) + try: + upper_km = float(parts[1]) + except Exception: + return None + rings_km = [float(r) / 1000.0 for r in (getattr(config, "distance_rings", None) or [])] + for idx, rk in enumerate(rings_km): + if abs(rk - upper_km) < 1e-6: + colors = getattr(config, "ring_colors", None) or [] + return colors[idx] if idx < len(colors) else None + return None + + +def _add_risk_color_legend(doc: Document, size_pt: int = 10) -> None: + from src.utils import get_risk_definition_by_fixed_intervals, get_turbine_color_by_fixed_intervals + + legend_items: list[tuple[str, str]] = [ + ("Very Low Risk (< 0.1)", get_turbine_color_by_fixed_intervals(0.05)), + ("Low Risk (0.1 - 0.2)", get_turbine_color_by_fixed_intervals(0.15)), + ("Med-Low Risk (0.2 - 0.4)", get_turbine_color_by_fixed_intervals(0.30)), + ("Medium Risk (0.4 - 0.6)", get_turbine_color_by_fixed_intervals(0.50)), + ("Med-High Risk (0.6 - 0.8)", get_turbine_color_by_fixed_intervals(0.70)), + ("High Risk (0.8 - 1.0)", get_turbine_color_by_fixed_intervals(0.90)), + ("Very High Risk (1.0 - 1.2)", get_turbine_color_by_fixed_intervals(1.10)), + ("Critical Risk (1.2 - 1.4)", get_turbine_color_by_fixed_intervals(1.30)), + ("Maximum Risk (≥ 1.4)", get_turbine_color_by_fixed_intervals(1.50)), + ] + + _add_paragraph(doc, "Risk Color Legend (Log Risk):", size_pt=size_pt, bold=True) + for label, color in legend_items: + p = doc.add_paragraph() + c = str(color).strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + marker = p.add_run("■ ") + marker.font.size = Pt(size_pt) + marker.font.color.rgb = RGBColor.from_string(c.upper()) + r = p.add_run(label) + r.font.size = Pt(size_pt) + + +def _add_image_from_bytes(doc: Document, png_bytes: bytes, width_inches: float) -> None: + stream = io.BytesIO(png_bytes) + doc.add_picture(stream, width=Inches(width_inches)) + + +def _render_math_to_png_bytes(latex: str, font_size: int = 18, dpi: int = 300) -> bytes: + fig = plt.figure(figsize=(0.01, 0.01), dpi=dpi) + fig.patch.set_alpha(0.0) + ax = fig.add_axes([0, 0, 1, 1]) + ax.axis("off") + text = ax.text( + 0, + 0, + f"${latex}$", + fontsize=font_size, + ha="left", + va="bottom", + ) + fig.canvas.draw() + bbox = text.get_window_extent(renderer=fig.canvas.get_renderer()).expanded(1.05, 1.25) + width_in = bbox.width / dpi + height_in = bbox.height / dpi + fig.set_size_inches(width_in, height_in) + ax.set_position([0, 0, 1, 1]) + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, transparent=True, bbox_inches="tight", pad_inches=0.02) + plt.close(fig) + return buf.getvalue() + + +def _add_math_formula( + doc: Document, + latex: str, + width_inches: float, + align: WD_ALIGN_PARAGRAPH = WD_ALIGN_PARAGRAPH.LEFT, + font_size: int = 18, +) -> None: + png = _render_math_to_png_bytes(latex, font_size=font_size) + p = doc.add_paragraph() + p.alignment = align + run = p.add_run() + run.add_picture(io.BytesIO(png), width=Inches(width_inches)) + + +_NAMED_COLOR_HEX: dict[str, str] = { + "white": "FFFFFF", + "lightgrey": "D9D9D9", + "lightgray": "D9D9D9", + "grey": "BFBFBF", + "gray": "BFBFBF", + "purple": "800080", + "red": "FF0000", + "orange": "FFA500", + "coral": "FF7F50", + "green": "008000", + "blue": "0000FF", + "pink": "FFC0CB", + "yellow": "FFFF00", + "lightgreen": "90EE90", + "lightblue": "ADD8E6", +} + + +def _color_to_hex(color: str | None) -> str | None: + if not color: + return None + s = str(color).strip() + if not s: + return None + if s.startswith("#") and len(s) == 7: + return s[1:].upper() + if s.startswith("rgb"): + try: + inside = s[s.find("(") + 1 : s.find(")")] + parts = [p.strip() for p in inside.split(",")] + if len(parts) >= 3: + r, g, b = (max(0, min(255, int(float(parts[0])))), + max(0, min(255, int(float(parts[1])))), + max(0, min(255, int(float(parts[2]))))) + return f"{r:02X}{g:02X}{b:02X}" + except Exception: + return None + key = s.lower() + return _NAMED_COLOR_HEX.get(key) + + +def _shade_cell(cell, hex_color: str) -> None: + tc_pr = cell._tc.get_or_add_tcPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), hex_color) + tc_pr.append(shd) + + +def _fit_one_line_table_layout( + table_data: list[list[str]], + available_width_cm: float, + min_col_widths_cm: list[float], + max_font_pt: int = 10, + min_font_pt: int = 6, +) -> tuple[int, list[float]]: + cols = len(table_data[0]) if table_data else 0 + if cols == 0: + return max_font_pt, [] + + max_lens = [ + max((len(str(row[c])) for row in table_data if c < len(row)), default=0) + for c in range(cols) + ] + + def chars_per_cm(font_pt: int) -> float: + return 45.0 / float(font_pt) + + for font_pt in range(max_font_pt, min_font_pt - 1, -1): + cpc = chars_per_cm(font_pt) + required = [ + max(min_col_widths_cm[c], (max_lens[c] / cpc) * 1.05) for c in range(cols) + ] + total_required = sum(required) + if total_required <= available_width_cm: + extra = available_width_cm - total_required + weight_sum = float(sum(max_lens) or 1) + widths = [ + required[c] + extra * (max_lens[c] / weight_sum) for c in range(cols) + ] + return font_pt, widths + + font_pt = min_font_pt + cpc = chars_per_cm(font_pt) + required = [ + max(min_col_widths_cm[c], (max_lens[c] / cpc) * 1.05) for c in range(cols) + ] + total_required = sum(required) or 1.0 + scale = available_width_cm / total_required + return font_pt, [w * scale for w in required] + + +def _add_table( + doc: Document, + table_data: list[list[str]], + row_colors: list[str] | None = None, + header_bold: bool = True, + column_widths_cm: list[float] | None = None, + font_size_pt: float | None = None, + autofit: bool | None = None, +) -> None: + if not table_data: + return + rows = len(table_data) + cols = len(table_data[0]) + t = doc.add_table(rows=rows, cols=cols) + t.style = "Table Grid" + if autofit is not None: + t.autofit = bool(autofit) + for r_idx, row in enumerate(table_data): + for c_idx, val in enumerate(row): + cell = t.cell(r_idx, c_idx) + cell.text = str(val) + if font_size_pt is not None: + for p in cell.paragraphs: + p.paragraph_format.space_after = Pt(0) + p.paragraph_format.space_before = Pt(0) + p.paragraph_format.line_spacing = 1.0 + for run in p.runs: + run.font.size = Pt(font_size_pt) + if r_idx == 0 and header_bold: + for run in cell.paragraphs[0].runs: + run.bold = True + if row_colors and r_idx < len(row_colors): + hex_color = _color_to_hex(row_colors[r_idx]) + if hex_color and hex_color != "FFFFFF": + _shade_cell(cell, hex_color) + if column_widths_cm and len(column_widths_cm) == cols: + for c_idx, w in enumerate(column_widths_cm): + for r_idx in range(rows): + t.cell(r_idx, c_idx).width = Cm(w) + + +def create_docx_report( + docx_path: str, + turbine_df: pd.DataFrame, + lightning_df: pd.DataFrame, + storm_data_path: str | None = None, + storm_data_records: list[dict[str, Any]] | dict[str, Any] | None = None, +) -> None: + start_date = config.analysis_start_date + end_date = config.analysis_end_date + + doc = Document() + _set_document_page_setup(doc, DocxPageConfig()) + content_width = _content_width_inches(doc) + _add_footer_page_number_left(doc) + + # Cover + _add_cover_logo_centered(doc) + _add_title(doc, "Lightning Activity Report for", size_pt=22, align=WD_ALIGN_PARAGRAPH.CENTER) + _add_title(doc, f"{config.wind_farm_name or ''}", size_pt=22, align=WD_ALIGN_PARAGRAPH.CENTER) + + period_has_time = (start_date and " " in str(start_date) and ":" in str(start_date)) or (end_date and " " in str(end_date) and ":" in str(end_date)) + if period_has_time: + utc_label = get_utc_offset_label(getattr(config, "timezone", None)) + period_label = f"Analyzed Period ({utc_label}):" if utc_label else "Analyzed Period (local time):" + else: + period_label = "Analyzed Period:" + + _add_paragraph(doc, period_label, size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER, bold=True) + _add_paragraph(doc, f"{start_date or ''} - {end_date or ''}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER) + centroid_lat = float(turbine_df["lat"].mean()) if len(turbine_df) > 0 else 0.0 + centroid_lng = float(turbine_df["lng"].mean()) if len(turbine_df) > 0 else 0.0 + _add_paragraph(doc, "Centroid Coordinates:", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER, bold=True) + _add_paragraph(doc, f"Latitude: {centroid_lat:.5f}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER) + _add_paragraph(doc, f"Longitude: {centroid_lng:.5f}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER) + _add_paragraph(doc, f"Report Generated: {format_datetime_ddmmyyyy_hhmm(now_in_timezone(getattr(config, 'timezone', None)))}", size_pt=11, align=WD_ALIGN_PARAGRAPH.CENTER) + _add_paragraph(doc, "This report provides comprehensive lightning and storm activity analysis for wind farm", size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER) + _add_paragraph(doc, "operations and safety assessment. Each section includes detailed explanations to help", size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER) + _add_paragraph(doc, "you understand the data and make informed decisions about turbine safety", size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER) + doc.add_page_break() + + _add_header_logo(doc) + + # Optional storm data load/filter (same behavior as PDF) + storm_data: list[dict[str, Any]] = [] + if storm_data_records is not None: + # `storm_records` may come back as: + # - list[dict] (expected), or + # - dict[id, list[dict]] (common for APIs) + # If we naively do `list(storm_data_records)` we would get dict keys (strings), + # which later breaks `.get(...)` access in storm filtering helpers. + if isinstance(storm_data_records, list): + storm_data = [s for s in storm_data_records if isinstance(s, dict)] + else: + normalized: list[dict[str, Any]] = [] + for value in storm_data_records.values(): + if isinstance(value, dict): + normalized.append(value) + elif isinstance(value, list): + normalized.extend([item for item in value if isinstance(item, dict)]) + storm_data = normalized + elif storm_data_path: + storm_data = load_storm_data_from_json(storm_data_path) + + if storm_data: + storm_data = filter_storm_data_by_date_range(storm_data, start_date, end_date) + storm_data = filter_storm_data_by_turbine_proximity(storm_data, turbine_df) + + # Compute risks and groups + turbine_df = calculate_turbine_risks(turbine_df, lightning_df) + group_data = create_turbine_groups(turbine_df) + stats = calculate_lightning_statistics(lightning_df, centroid_lat, centroid_lng, start_date, end_date) + histogram_figs = create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng) + + # Turbine information + _add_title(doc, "Turbine Information", size_pt=16, align=WD_ALIGN_PARAGRAPH.LEFT) + _add_paragraph(doc, "This table contains detailed information about all turbines in the wind farm.", size_pt=10) + turb_rows: list[list[str]] = [[ + "Turbine", "Lat", "Lng", "Unit Power (MWm)", "Unit Power (MWe)", "Tower Height (m)", "Rotor Diameter (m)", "Altitude (m)", + ]] + for _, t in turbine_df.iterrows(): + turb_rows.append([ + str(t.get("name", "N/A")), + f"{t.get('lat', 0):.4f}", + f"{t.get('lng', 0):.4f}", + str(t.get("unit_power_mwm", "N/A")), + str(t.get("unit_power_mwe", "N/A")), + str(t.get("tower_height_m", "N/A")), + str(t.get("turbine_rotor_blade_diameter", "N/A")), + str(t.get("altitude", "N/A")), + ]) + _add_table(doc, turb_rows) + + # Gemini commentary (single API call per report run; falls back deterministically if Gemini is unavailable) + analysis_radius_km = float(get_analysis_radius_m()) / 1000.0 if get_analysis_radius_m() > 0 else float(max(config.distance_rings) / 1000.0) + analysis_period_str = f"{start_date or ''} - {end_date or ''}".strip(" -") + + ring_stats: dict[str, Any] = stats.get("lightning_by_distance_rings") or {} + # Align the "outermost" ring used for commentary with the analysis radius used in stats. + outermost_upper_km = analysis_radius_km + + def _ring_upper_km(label: str) -> float | None: + s = str(label).strip() + if ":" in s: + s = s.split(":", 1)[0] + s = s.lower().replace("km", "").strip() + # Expected formats: "0-2.0" or "2.0-4.0" + if "-" not in s: + return None + try: + upper = float(s.split("-", 1)[1]) + return upper + except Exception: + return None + + rings = [] + for ring_name, ring_data in ring_stats.items(): + total = int(ring_data.get("total", 0)) + cg_count = int(ring_data.get("cloud_to_ground", 0)) + ic_count = int(ring_data.get("intercloud", 0)) + rings.append((ring_name, total, cg_count, ic_count)) + + # Rank "top rings" by cloud-to-ground contribution (turbine risk score is CG-based). + best_rings_desc = sorted(rings, key=lambda x: x[2], reverse=True) + outermost_ring = None + for ring in rings: + upper_km = _ring_upper_km(ring[0]) + if upper_km is not None and abs(upper_km - outermost_upper_km) < 1e-6: + outermost_ring = ring + break + if outermost_ring is None and rings: + outermost_ring = max(rings, key=lambda r: _ring_upper_km(r[0]) or 0.0) + + top_rings: list[tuple[str, int, int, int]] = [] + for ring in best_rings_desc: + if outermost_ring is not None and ring[0] == outermost_ring[0]: + continue + top_rings.append(ring) + if len(top_rings) >= 2: + break + if outermost_ring is not None: + top_rings.append(outermost_ring) + + risk_log_max = None + max_risk_definition = None + top_turbine_name: str | None = None + top_turbine_risk_log: float | None = None + turbine_risk_counts: dict[str, int] = {} + if "risk_log" in turbine_df.columns and len(turbine_df) > 0: + risk_log_max = float(turbine_df["risk_log"].max()) + top_idx = turbine_df["risk_log"].idxmax() + top_turbine = turbine_df.loc[top_idx] + top_turbine_name = str(top_turbine.get("name", "N/A")) + top_turbine_risk_log = float(top_turbine.get("risk_log", risk_log_max)) + max_risk_definition = get_risk_definition_by_fixed_intervals(risk_log_max) + for rl in turbine_df["risk_log"].tolist(): + defn = get_risk_definition_by_fixed_intervals(float(rl)) + turbine_risk_counts[defn] = turbine_risk_counts.get(defn, 0) + 1 + + storm_summary = create_storm_cells_summary(storm_data) if storm_data else None + + # Storm interaction with the highest-risk turbine: we approximate "over the turbine" + # by checking whether the storm cell centroid is within a small distance threshold. + # This provides a robust summary even without polygon point-in-polygon tests. + turbine_count = len(turbine_df) + is_single_turbine_report = turbine_count == 1 + storm_over_turbine = None + storm_near_turbine_count = 0 + storm_closest_distance_km: float | None = None + storm_over_threshold_km = 1.0 + + if storm_data and top_turbine_name is not None and "risk_log" in turbine_df.columns and len(turbine_df) > 0: + # Use the coordinates of the highest-risk turbine for storm-distance calculations. + if "lat" in turbine_df.columns and "lng" in turbine_df.columns and top_turbine_name is not None: + top_row = turbine_df.loc[turbine_df["name"] == top_turbine_name].head(1) if "name" in turbine_df.columns else None + if top_row is not None and len(top_row) > 0: + top_lat = float(top_row.iloc[0]["lat"]) + top_lng = float(top_row.iloc[0]["lng"]) + min_dist = float("inf") + near_count = 0 + for storm in storm_data: + wkt_string = storm.get("cell_polygon_wkt", "") or "" + if not wkt_string: + continue + centroid = calculate_storm_cell_centroid(wkt_string) + if centroid is None: + continue + c_lat, c_lng = centroid + dist_km = haversine_distance(top_lat, top_lng, c_lat, c_lng) / 1000.0 + if dist_km < min_dist: + min_dist = dist_km + if dist_km <= storm_over_threshold_km: + near_count += 1 + if min_dist != float("inf"): + storm_closest_distance_km = min_dist + storm_near_turbine_count = near_count + storm_over_turbine = near_count > 0 + + commentary_context: dict[str, Any] = { + "analysis_period": analysis_period_str, + "analysis_radius_km": round(float(analysis_radius_km), 1) if analysis_radius_km is not None else None, + "total_events": int(stats.get("total_events", 0) or 0), + "total_lightning_per_km2": round(float(stats.get("total_lightning_per_km2", 0) or 0), 3), + "top_rings": top_rings, + "max_risk_log": round(float(risk_log_max), 3) if risk_log_max is not None else None, + "max_risk_definition": max_risk_definition, + "top_turbine_name": top_turbine_name, + "top_turbine_risk_log": round(float(top_turbine_risk_log), 3) if top_turbine_risk_log is not None else None, + "turbine_count": turbine_count, + "is_single_turbine_report": is_single_turbine_report, + "storm_over_turbine": storm_over_turbine, + "storm_near_turbine_count": storm_near_turbine_count, + "storm_closest_distance_km": round(float(storm_closest_distance_km), 1) if storm_closest_distance_km is not None else None, + "storm_over_threshold_km": storm_over_threshold_km, + "turbine_risk_counts": turbine_risk_counts, + "storm_summary": storm_summary, + } + + commentary_text = generate_gemini_paragraph(commentary_context) + commentary_text = commentary_text.strip() + # Defensive cleanup: sometimes models may prepend a heading/label. + for prefix in ("Gemini Commentary:", "Gemini commentary:", "Commentary:", "Commentary"): + if commentary_text.startswith(prefix): + commentary_text = commentary_text[len(prefix):].strip() + break + # Keep the commentary close to the Turbine Information table (same page if possible). + doc.add_paragraph("") + doc.add_paragraph("") + _add_title(doc, "Report Summary", size_pt=14, bold=True, align=WD_ALIGN_PARAGRAPH.LEFT) + # DOCX line spacing for the Gemini commentary paragraph. + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.LEFT + r = p.add_run(commentary_text) + r.font.size = Pt(10) + p.paragraph_format.line_spacing = 1.5 + + doc.add_page_break() + + # Group maps + charts + risk tables + for group_info in group_data["groups"]: + centroid_row = pd.Series({ + "lat": group_info["centroid_lat"], + "lng": group_info["centroid_lng"], + "name": f"Group {group_info['group_id'] + 1}", + }) + pre = precompute_group_distances_and_rings(centroid_row["lat"], centroid_row["lng"], lightning_df, config.distance_rings) + + _add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + cg_fig = plot_cloud_to_ground_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) + _add_image_from_bytes(doc, cg_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() + + _add_title(doc, "Cloud-to-Ground — Current vs Distance", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + cg_chart = _build_current_vs_distance_chart( + lightning_df, + pre["dists_km"], + pre["mask_within"], + "cg", + "Cloud-to-Ground Lightning — Current vs Distance from Centroid", + 900, + 650, + ) + if cg_chart is not None: + _add_image_from_bytes(doc, cg_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() + + _add_title(doc, "Intercloud Lightnings", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + ic_fig = plot_intercloud_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) + _add_image_from_bytes(doc, ic_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() + + _add_title(doc, "Intercloud — Current vs Distance", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + ic_chart = _build_current_vs_distance_chart( + lightning_df, + pre["dists_km"], + pre["mask_within"], + "ic", + "Intercloud Lightning — Current vs Distance from Centroid", + 900, + 650, + ) + if ic_chart is not None: + _add_image_from_bytes(doc, ic_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() + + risk_table_data, risk_row_colors = build_risk_table_data(turbine_df, group_info) + if risk_table_data and len(risk_table_data) > 1: + _add_title(doc, "Turbine Risk Assessment", size_pt=14) + _add_table(doc, risk_table_data, row_colors=risk_row_colors) + doc.add_page_break() + + # Daily breakdown + _add_title(doc, "Lightning Breakdown by Distance Rings", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"Period: {start_date} - {end_date} (Total: {stats.get('total_events', 0)} lightning events)", size_pt=10, bold=True) + closest_km = (config.distance_rings[0] / 1000.0) if getattr(config, "distance_rings", None) else 1.0 + + analysis_radius_km = get_analysis_radius_m() / 1000.0 if get_analysis_radius_m() > 0 else closest_km + _add_paragraph(doc, f"Area covered within {analysis_radius_km:.1f} km radius: {stats.get('area_km2', 0):.1f} km²", size_pt=10) + _add_paragraph(doc, f"Total lightnings within {analysis_radius_km:.1f} km radius: {stats.get('total_events', 0)} events", size_pt=10) + _add_paragraph(doc, f"Total lightning density: {stats.get('total_lightning_per_km2', 0):.3f} events/km²", size_pt=10) + _add_paragraph( + doc, + f"(Calculation: {stats.get('total_events', 0)} total lightnings / {stats.get('area_km2', 0):.1f} km² area)", + size_pt=9, + ) + + period_days = float(stats.get("period_days", 0.0) or 0.0) + if period_days >= 1.0: + _add_paragraph(doc, f"Daily equivalent density: {stats.get('daily_lightning_per_km2', 0):.3f} events/km²/day", size_pt=10) + if period_days == 1.0: + days_label = "1 day in the period" + elif period_days != int(period_days): + days_label = f"{period_days:.1f} days in the period" + else: + days_label = f"{int(period_days)} days in the period" + _add_paragraph( + doc, + f"(Calculation: {stats.get('total_events', 0)} total lightnings / {stats.get('area_km2', 0):.1f} km² area / {days_label})", + size_pt=9, + ) + + _add_paragraph(doc, "This section shows lightning events over the analyzed period by distance from turbines.", size_pt=10) + _add_paragraph(doc, f"Higher counts in closer rings (0-{closest_km:.1f}km) indicate elevated risk to turbine operations.", size_pt=10) + ring_items: list[str] = [] + for ring_name, ring_data in sorted((stats.get("lightning_by_distance_rings") or {}).items()): + if ring_data.get("total", 0) > 0: + ring_items.append(f"{ring_name}: {ring_data['total']} total ({ring_data['cloud_to_ground']} cloud-to-ground, {ring_data['intercloud']} intercloud)") + if ring_items: + for item in ring_items: + _add_ring_bullet(doc, item, _ring_color_for_label(item), size_pt=10) + doc.add_page_break() + + # Histograms + for idx, fig in enumerate(histogram_figs): + if idx == 0: + _add_title(doc, "Frequent Lightning Activity Report", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10, bold=True) + analysis_radius_km = get_analysis_radius_m() / 1000.0 if get_analysis_radius_m() > 0 else closest_km + _add_paragraph(doc, "Lightning Activity Overview:", size_pt=11, bold=True) + _add_paragraph( + doc, + f"The chart below shows lightning activity patterns over time within {analysis_radius_km:.1f} km radius, helping identify high-risk periods and concentrated storm events. Peaks indicate intense lightning activity requiring attention.", + size_pt=10, + ) + _add_paragraph( + doc, + 'For detailed information go to the "Frequent Lightning Activity Period Detection Algorithm" section in the appendix.', + size_pt=10, + ) + else: + _add_title(doc, "Frequent Lightning Activity Periods", size_pt=14) + _add_image_from_bytes(doc, fig.to_image(format="png", width=1400, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() + + # Storm cells (summary per day) + if storm_data: + _add_title(doc, "Storm Cells Analysis Summary", size_pt=16) + _add_paragraph(doc, f"{start_date or ''} - {end_date or ''}", size_pt=12, align=WD_ALIGN_PARAGRAPH.LEFT, bold=True) + _add_paragraph(doc, "Storm Cells Overview:", size_pt=11, bold=True) + _add_paragraph( + doc, + "The following pages show storm cell boundaries organized by day during the analysis period.", + size_pt=10, + ) + _add_paragraph( + doc, + "Each cell represents a storm system with defined boundaries and storm severity levels.", + size_pt=10, + ) + + summary = create_storm_cells_summary(storm_data) + _add_paragraph(doc, "Storm Cells Summary:", size_pt=11, bold=True) + _add_paragraph(doc, f"Total storm cells: {summary.get('total_cells', 0)}", size_pt=10) + + severity_counts: dict[str, int] = summary.get("severity_counts", {}) or {} + _add_paragraph(doc, "Severity breakdown:", size_pt=10, bold=True) + _add_bullets(doc, [f"{severity}: {count} cells" for severity, count in severity_counts.items()], size_pt=10) + + _add_paragraph(doc, f"Average direction: {summary.get('avg_direction', 0):.1f}°", size_pt=10) + _add_paragraph(doc, f"Average speed: {summary.get('avg_speed', 0):.1f} km/h", size_pt=10) + + daily_breakdown: dict[str, int] = summary.get("daily_breakdown", {}) or {} + _add_paragraph(doc, "Daily Storm Breakdown:", size_pt=10, bold=True) + sorted_days = sorted(daily_breakdown.keys()) + daily_items: list[str] = [] + for day in sorted_days[:10]: + daily_items.append(f"{day}: {daily_breakdown.get(day, 0)} storm cells") + if len(sorted_days) > 10: + daily_items.append(f"... and {len(sorted_days) - 10} more days") + if daily_items: + _add_bullets(doc, daily_items, size_pt=10) + + _add_paragraph(doc, "Complete Storm Cells List:", size_pt=10, bold=True) + + def _storm_effective_time(storm: dict[str, Any]) -> datetime: + raw = storm.get("effective_time") or storm.get("creation_time") or storm.get("expire_time") or "" + try: + # `format_datetime_to_local_display` can format, but for sorting we need a sortable key. + dt = pd.to_datetime(str(raw), utc=True, errors="coerce") + if pd.isna(dt): + return datetime.min + return dt.to_pydatetime() + except Exception: + return datetime.min + + sorted_storms = sorted(storm_data, key=_storm_effective_time) + table_data: list[list[str]] = [["No.", "Severity", "Effective Time", "Expire Time"]] + row_colors: list[str] = ["lightgrey"] + for i, storm in enumerate(sorted_storms, start=1): + report_tz = getattr(config, "timezone", None) + effective_raw = storm.get("effective_time") or storm.get("creation_time") or "" + expire_raw = storm.get("expire_time") or "" + + effective_formatted = format_datetime_to_local_display(effective_raw, report_tz) + expire_formatted = format_datetime_to_local_display(expire_raw, report_tz) + + table_data.append( + [ + str(i), + str(storm.get("lightning_severity", "Unknown")), + effective_formatted, + expire_formatted, + ] + ) + severity = str(storm.get("lightning_severity", "Unknown") or "Unknown").strip().lower() + if severity == "high": + row_colors.append("purple") + elif severity == "medium": + row_colors.append("orange") + elif severity == "low": + row_colors.append("green") + else: + row_colors.append("gray") + + # Keep the list compact: small font, fixed column widths + _add_table( + doc, + table_data, + row_colors=row_colors, + font_size_pt=8, + autofit=False, + column_widths_cm=[1.0, 2.2, 3.6, 3.6], + ) + + doc.add_page_break() + + _add_title(doc, "Storm Cells", size_pt=14) + _add_paragraph(doc, "Daily storm cells visualization.", size_pt=10) + storm_fig = create_storm_cells_map(storm_data, turbine_df) + _add_image_from_bytes( + doc, + storm_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), + content_width, + ) + doc.add_page_break() + + # Lightning event tables per group + for group_info in group_data["groups"]: + _add_title(doc, "Detailed Lightning Event Data", size_pt=14) + table_data, row_colors = build_group_lightning_table_data(group_info["centroid_lat"], group_info["centroid_lng"], lightning_df) + if table_data and table_data[0]: + table_data[0] = [ + str(h) + .replace("Time (Local)", "Time\u00A0(Local)") + .replace("Current (amps)", "Current\u00A0(amps)") + .replace("Height (m)", "Height\u00A0(m)") + .replace("Lightning Type", "Lightning\u00A0Type") + .replace("Proximity (km)", "Proximity\u00A0(km)") + for h in table_data[0] + ] + available_width_cm = float(content_width) * 2.54 + min_col_widths_cm = [1, 3.2, 1.6, 1.6, 1.9, 1.4, 2.0, 1.8] + font_pt, col_widths_cm = _fit_one_line_table_layout( + table_data=table_data, + available_width_cm=available_width_cm, + min_col_widths_cm=min_col_widths_cm, + max_font_pt=15, + min_font_pt=8, + ) + _add_table( + doc, + table_data, + row_colors=row_colors, + column_widths_cm=col_widths_cm, + font_size_pt=float(font_pt), + autofit=False, + ) + doc.add_page_break() + + # Appendix + _add_title(doc, "Appendix", size_pt=16) + # 1. Risk Calculation Method + _add_title(doc, "1. Risk Calculation Method", size_pt=14) + _add_paragraph(doc, "How Risk Scores Are Determined:", size_pt=11, bold=True) + _add_paragraph(doc, "A turbine’s risk score represents its exposure to cloud-to-ground lightning during the analysis period.", size_pt=10) + _add_paragraph(doc, "Each lightning strike contributes some risk based on distance to the turbine and the strike’s current magnitude, and these contributions are summed.", size_pt=10) + _add_math_formula( + doc, + r"r(d, I) = P_{0} \times \left(1 + \mathrm{current\_weight}\,\frac{|I|}{10000}\right) \times e^{-\alpha\,d}", + width_inches=min(content_width, 6.5), + ) + _add_paragraph(doc, "P0 (base factor): baseline scaling applied to every strike’s contribution", size_pt=10) + _add_paragraph(doc, "current_weight: controls how strongly current magnitude increases risk (larger = more weight on |I|)", size_pt=10) + _add_paragraph(doc, "Distance (d): turbine-to-strike distance in kilometers", size_pt=10) + _add_paragraph(doc, "Current (|I|): absolute current magnitude |I| in amperes", size_pt=10) + _add_paragraph(doc, "α (distance decay factor): controls how risk decays with distance (smaller = slower decay)", size_pt=10) + _add_bullets( + doc, + [ + "Included events: only cloud-to-ground lightning (p_type = 0)", + "Per-strike contribution: increases with |I| and decreases exponentially with distance", + ], + size_pt=10, + ) + _add_paragraph(doc, "Turbine's risk score: sum of per-strike contributions for all included strikes (typically within the outermost distance ring)", size_pt=10) + _add_math_formula( + doc, + r"\mathrm{turbine\_risk\_score}(t) = \sum_{j \in \mathcal{S}} r\!\left(d_{t,j}, I_{j}\right)\quad \mathcal{S}=\{j:\ p\_type_j=0,\ d_{t,j}\leq R\}", + width_inches=min(content_width, 6.5), + font_size=26, + ) + _add_paragraph(doc, "For visualization and reporting, we use the log-transformed score", size_pt=10) + _add_math_formula( + doc, + r"\mathrm{risk\_log}(t) = \log_{10}(\mathrm{risk\_score}(t) + 1)", + width_inches=min(content_width, 6.5), + ) + + # 2. Risk Score Interpretation + _add_title(doc, "2. Risk Score Interpretation", size_pt=14) + _add_paragraph(doc, "Understanding Risk Score Values:", size_pt=11, bold=True) + _add_bullets( + doc, + [ + "Minimum Risk Score: ~0.001 (far distance, low current)", + "Maximum Risk Score: ~2.500 (close distance, high current)", + "Typical Range: 0.010 - 1.000 for most lightning events", + ], + size_pt=10, + ) + _add_paragraph(doc, "Risk Score Categories (Fixed Color Intervals):", size_pt=11, bold=True) + _add_bullets( + doc, + [ + "Very Low Risk (<0.1): Blue - Distant lightning with low current", + "Low Risk (0.1-0.2): Teal - Moderate distance lightning", + "Med-Low Risk (0.2-0.4): Green - Closer lightning", + "Medium Risk (0.4-0.6): Yellow - Moderate risk lightning", + "Med-High Risk (0.6-0.8): Orange - High risk lightning", + "High Risk (0.8-1.0): Dark Orange - Very high risk lightning", + "Very High Risk (1.0-1.2): Red - Extreme risk lightning", + "Critical Risk (>1.2): Dark Red - Critical risk lightning", + ], + size_pt=10, + ) + + # 3. Risk Score Calculation Chart + _add_title(doc, "3. Risk Score Calculation Chart", size_pt=14) + _add_paragraph(doc, "Chart Reference Guide:", size_pt=11, bold=True) + _add_paragraph(doc, "The following chart shows how distance and current magnitude affect risk scores.", size_pt=10) + _add_paragraph(doc, "Use this chart to interpret the risk scores in the main report.", size_pt=10) + _add_bullets(doc, ["Red areas = High risk (close distance, high current)", "Yellow/Orange areas = Medium risk", "Blue/Green areas = Low risk (far distance, low current)"], size_pt=10) + from src.visualization.maps import create_risk_score_heatmap + heatmap_fig = create_risk_score_heatmap() + _add_image_from_bytes(doc, heatmap_fig.to_image(format="png", width=1400, height=900, scale=2, engine="kaleido"), content_width) + + # 4. Centroid and Distance Ring Calculation + _add_title(doc, "4. Centroid and Distance Ring Calculation", size_pt=14) + _add_bullets( + doc, + [ + "Turbines are grouped by proximity (within 4km of each other)", + "Group centroid = Average latitude and longitude of all turbines in the group", + "Distance rings are drawn from the group centroid, not individual turbines", + "Single isolated turbines get their own centroid (individual analysis)", + "Distance calculations use Haversine formula for accurate geographic distances", + ], + size_pt=10, + ) + + # 5. Turbine Grouping Method + _add_title(doc, "5. Turbine Grouping Method", size_pt=14) + _add_paragraph(doc, "How Turbines Are Grouped for Analysis:", size_pt=11, bold=True) + _add_paragraph(doc, "The system uses an intelligent clustering algorithm to group turbines based on geographic proximity.", size_pt=10) + _add_paragraph(doc, "Grouping Algorithm Process:", size_pt=11, bold=True) + from src.utils import get_grouping_radius_m + grouping_radius_m = get_grouping_radius_m() + grouping_radius_km = grouping_radius_m / 1000.0 + _add_bullets( + doc, + [ + "Proximity Analysis: All turbines are analyzed for pairwise distances using Haversine formula", + "Clustering Method: Uses DBSCAN (Density-Based Spatial Clustering) algorithm when available", + f"Dynamic Distance Threshold: Configurable grouping radius: {grouping_radius_km:.1f}km ({grouping_radius_m}m)", + "Fallback Algorithm: If DBSCAN unavailable, uses iterative proximity grouping", + "Group Formation: Each group contains turbines that are mutually within the distance threshold", + ], + size_pt=10, + ) + _add_paragraph(doc, "Group Types and Analysis:", size_pt=11, bold=True) + _add_bullets( + doc, + [ + "Multi-Turbine Groups: Multiple turbines within proximity range - analyzed as a cluster", + "Single Turbine Groups: Isolated turbines beyond grouping threshold - individual analysis", + "Centroid Calculation: Each group's center point calculated as mean of member coordinates", + "Distance Ring Origin: All proximity calculations use group centroid as reference point", + ], + size_pt=10, + ) + _add_paragraph(doc, "Benefits of Grouping Approach:", size_pt=11, bold=True) + _add_bullets( + doc, + [ + "Efficient Analysis: Reduces computational complexity for large wind farms", + "Logical Clustering: Groups turbines that experience similar weather conditions", + "Risk Assessment: Provides both group-level and individual turbine risk analysis", + "Scalability: Algorithm adapts to wind farms of any size and turbine distribution", + ], + size_pt=10, + ) + + # 6. Frequent Lightning Activity Period Detection Algorithm + _add_title(doc, "6. Frequent Lightning Activity Period Detection Algorithm", size_pt=14) + _add_paragraph(doc, "How Period Timespans Are Determined:", size_pt=11, bold=True) + _add_paragraph(doc, "The algorithm uses a gap-based approach to identify concentrated lightning activity periods.", size_pt=10) + _add_paragraph(doc, "Step-by-Step Process:", size_pt=11, bold=True) + _add_bullets( + doc, + [ + "Chronological Sorting: All lightning events are sorted by timestamp (local_time)", + "Gap Calculation: Time differences between consecutive lightning events are calculated", + f"Period Boundary Detection: When a gap between two consecutive events exceeds {config.histogram_params['min_gap_minutes']} minutes, it marks the end of one period and the start of another", + f"Period Validation: Only periods with ≥{config.histogram_params['min_events_per_period']} lightning events are considered significant", + "Timespan Definition: Start time = first event; End time = last event; Duration is actual time from first to last event", + "Peak Sub-Period Detection: 3-minute rolling average; peaks when activity exceeds mean + 1 standard deviation; highlighted in yellow on histogram", + ], + size_pt=10, + ) + + # 7. EarthNetworks Total Lightning Network (ENTLN) + _add_title(doc, "7. EarthNetworks Total Lightning Network (ENTLN)", size_pt=14) + _entln_blocks = [ + "The lightning data presented in this report is sourced directly from the EarthNetworks Total Lightning Network (ENTLN), which monitors both in-cloud and cloud-to-ground strikes. ENTLN is the first in-cloud lightning and cloud-to-ground detection network deployed on a global basis. It includes more than 1,500 sensors (the world's largest) that incorporate advanced systems and technology to provide unmatched in-cloud and cloud-to-ground lightning detection – total lightning detection.", + "A variety of government agencies, such as the National Weather Service, the Air Force Weather Agency, and organizations that include public safety and emergency response agencies, airports, and utilities rely on information from the ENTLN to make informed decisions.", + "Its unique capabilities detect long-range in-cloud lightning at high efficiencies, which are critical for the advanced prediction of severe weather such as:", + ] + for blk in _entln_blocks: + _add_paragraph(doc, blk, size_pt=10) + _add_bullets( + doc, + [ + "Tornadoes and cyclones", + "Heavy rainfall and monsoons", + "Downburst and wind shear", + "Cloud-to-Ground lightning strikes", + ], + size_pt=10, + ) + _add_paragraph(doc, "These potentially deadly weather events often occur within 5 to 30 minutes of in-cloud flash initiation. ENTLN has demonstrated the ability to significantly improve severe weather warning times over radar and other technologies by incorporating predictive capabilities that are crucial for characterizing severe storm precursors, improving severe storm warning lead times, and supporting comprehensive weather management planning.", size_pt=10) + _add_paragraph(doc, "The total lightning network has expanded to include more than 1,500 sensors in more than 40 countries around the world, including North and South America, the Caribbean, Europe, Australia, Africa and Asia. This dense sensor deployment enables high-efficiency capture of total lightning activity across these regions.", size_pt=10) + + earth_logo = _resolve_logo_path("earth_networks.jpg") + if os.path.isfile(earth_logo): + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run() + run.add_picture(earth_logo, width=Inches(2.2)) + + doc.save(docx_path) + diff --git a/src/reporting/docx_sections.py b/src/reporting/docx_sections.py new file mode 100644 index 0000000..e08783a --- /dev/null +++ b/src/reporting/docx_sections.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from typing import Any + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from src.config import config +from src.reporting.precompute import precompute_group_distances_and_rings + + +def _build_current_vs_distance_chart( + lightning_df: pd.DataFrame, + dists_km: np.ndarray, + mask_within: np.ndarray, + lightning_type_filter: str, + title: str, + fig_width: int, + fig_height: int, +) -> go.Figure | None: + if lightning_type_filter == "cg": + type_mask = lightning_df["p_type"].astype(str) == "0" + else: + type_mask = lightning_df["p_type"].astype(str) != "0" + + combined_mask = mask_within & type_mask + if combined_mask.sum() == 0: + return None + + subset = lightning_df.loc[combined_mask].copy() + distances = dists_km[combined_mask] + currents = subset["current"].values.astype(float) + + time_series = pd.to_datetime(subset["local_time"]) + time_dt = time_series.sort_values() + + rings_km = np.array(config.distance_rings, dtype=float) / 1000.0 + ring_colors_cfg = getattr(config, "ring_colors", None) or [] + ring_indices = np.searchsorted(rings_km, distances, side="left").astype(int) + ring_indices = np.clip(ring_indices, 0, len(rings_km) - 1) + + ring_names: list[str] = [] + for i in range(len(rings_km)): + if i == 0: + ring_names.append(f"0-{rings_km[0]:.1f} km") + else: + ring_names.append(f"{rings_km[i - 1]:.1f}-{rings_km[i]:.1f} km") + + t_min = time_dt.min() + t_max = time_dt.max() + tick_vals = pd.date_range(t_min, t_max, periods=4) + tick_text = [t.strftime("%d-%m-%Y %H:%M") for t in tick_vals] + + fig = go.Figure() + for i in range(len(rings_km)): + mask_ring = ring_indices == i + if mask_ring.sum() == 0: + continue + color = ring_colors_cfg[i] if i < len(ring_colors_cfg) else "gray" + r_times = time_series.values[mask_ring] + r_currents = currents[mask_ring] + r_dists = distances[mask_ring] + r_time_labels = pd.to_datetime(r_times).strftime("%d-%m-%Y %H:%M").values + fig.add_trace( + go.Scatter( + x=r_times, + y=r_currents, + mode="markers", + name=ring_names[i], + marker=dict(size=10, opacity=0.8, color=color), + customdata=np.column_stack((r_dists, r_time_labels)), + hovertemplate=( + "Time: %{customdata[1]}
" + "Current: %{y:.0f} A
" + "Distance: %{customdata[0]} km
" + "Ring: " + ring_names[i] + "
" + "" + ), + ) + ) + + fig.update_layout( + font=dict(size=16), + title=dict(text=title, x=0.5, font=dict(size=22)), + xaxis_title="Time", + yaxis_title="Current (A)", + plot_bgcolor="white", + paper_bgcolor="white", + xaxis=dict( + showgrid=True, + gridcolor="lightgray", + zeroline=False, + tickvals=tick_vals, + ticktext=tick_text, + tickangle=-25, + tickfont=dict(size=22), + title_font=dict(size=28), + ), + yaxis=dict( + showgrid=True, + gridcolor="lightgray", + zeroline=False, + tickfont=dict(size=22), + title_font=dict(size=28), + ), + legend=dict( + title="Distance Ring", + orientation="h", + x=0.5, + xanchor="center", + y=-0.28, + yanchor="top", + bgcolor="rgba(255,255,255,0.8)", + bordercolor="black", + borderwidth=1, + font=dict(size=20), + title_font=dict(size=24), + ), + width=fig_width, + height=fig_height, + margin=dict(l=70, r=40, t=50, b=130), + ) + + return fig + + +def build_group_lightning_table_data( + centroid_lat: float, centroid_lng: float, lightning_df: pd.DataFrame +) -> tuple[list[list[str]], list[str]]: + pre = precompute_group_distances_and_rings( + centroid_lat, centroid_lng, lightning_df, config.distance_rings + ) + rows: list[list[str]] = [] + row_colors: list[str] = [] + outermost_km = max(config.distance_rings) / 1000.0 + rings_km = [r / 1000.0 for r in config.distance_rings] + + for i, rec in enumerate(lightning_df.itertuples(index=False)): + proximity = float(pre["dists_km"][i]) + if proximity > outermost_km: + continue + ri = int(pre["ring_idx"][i]) + if ri >= len(rings_km): + continue + color = config.ring_colors[ri] + try: + from src.utils import format_datetime_ddmmyyyy_hhmmss + + dt_val = ( + rec.local_time + if not isinstance(rec.local_time, str) + else pd.to_datetime(str(rec.local_time)[:19]) + ) + local_time = format_datetime_ddmmyyyy_hhmmss(pd.to_datetime(dt_val)) + except Exception: + local_time = str(getattr(rec, "local_time", ""))[:19] + + lightning_type = "cloud-to-ground" if str(rec.p_type) == "0" else "intercloud" + height_val = getattr(rec, "ic_height", "") + if height_val == "": + height_val = getattr(rec, "height", "") + + rows.append( + [ + "", + local_time, + f"{rec.lat:.5f}", + f"{rec.lng:.5f}", + str(rec.current), + str(height_val), + lightning_type, + f"{proximity:.2f}", + ] + ) + row_colors.append(color) + + sorted_data = sorted( + zip(rows, row_colors), + key=lambda x: (0 if x[0][6] == "cloud-to-ground" else 1, float(x[0][7])), + ) + if sorted_data: + rows, row_colors = zip(*sorted_data) + rows = list(rows) + row_colors = list(row_colors) + for idx, row in enumerate(rows): + row[0] = str(idx + 1) + else: + rows, row_colors = [], [] + + header = [ + "#", + "Time (Local)", + "Lat", + "Lng", + "Current (amps)", + "Height (m)", + "Lightning Type", + "Proximity (km)", + ] + return [header] + rows, ["lightgrey"] + row_colors + + +def build_risk_table_data( + turbine_df: pd.DataFrame, group_info: dict[str, Any] +) -> tuple[list[list[str]] | None, list[str] | None]: + if "risk_log" not in turbine_df.columns: + return None, None + + group_turbines = turbine_df.iloc[group_info["turbine_indices"]] + rows: list[list[str]] = [] + row_colors: list[str] = [] + + from src.utils import get_risk_definition_by_fixed_intervals, get_turbine_color_by_fixed_intervals + + for _, turbine in group_turbines.iterrows(): + risk_log = float(turbine.get("risk_log", 0) or 0) + color = get_turbine_color_by_fixed_intervals(risk_log) + rows.append( + [ + str(turbine.get("name", "N/A")), + f"{risk_log:.2f}", + str(get_risk_definition_by_fixed_intervals(risk_log)), + ] + ) + row_colors.append(str(color)) + + sorted_data = sorted(zip(rows, row_colors), key=lambda x: float(x[0][1]), reverse=True) + if sorted_data: + rows, row_colors = zip(*sorted_data) + else: + rows, row_colors = [], [] + + header = ["Turbine Name", "Log Risk", "Risk Definition"] + return [header] + list(rows), ["lightgrey"] + list(row_colors) + diff --git a/src/reporting/filename_utils.py b/src/reporting/filename_utils.py new file mode 100644 index 0000000..2d24602 --- /dev/null +++ b/src/reporting/filename_utils.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +import pandas as pd + + +_DD_MM_YYYY_RE = re.compile(r"^\d{2}-\d{2}-\d{4}$") + + +def slugify_ascii_underscore(value: str) -> str: + """ + Convert `value` into an ASCII-only slug suitable for filenames. + + Rules: + - spaces -> underscore + - keep [A-Za-z0-9._-], convert everything else to underscore + - collapse consecutive underscores + - trim leading/trailing underscores + """ + if value is None: + return "report" + + s = str(value).strip() + if not s: + return "report" + + s = s.replace(" ", "_") + # Remove diacritics while preserving base ASCII letters (e.g. 'ğ' -> 'g'). + s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii") + s = re.sub(r"[^A-Za-z0-9._-]+", "_", s) + s = re.sub(r"_+", "_", s).strip("_") + return s or "report" + + +@dataclass(frozen=True) +class FarmLocalDateRange: + start_date_yyyy_mm_dd: str + end_date_yyyy_mm_dd: str + + +def _parse_date_value_local(value: str, tz: Optional[ZoneInfo]) -> date: + """ + Parse a date-like string from config into a local `date` in the given timezone. + + Config supported formats: + - DD-MM-YYYY (treated as already-local calendar date) + - ISO datetime (converted to tz, then local calendar date extracted) + """ + v = str(value).strip() + + if _DD_MM_YYYY_RE.match(v): + # Local calendar date, no timezone conversion needed. + dt = datetime.strptime(v, "%d-%m-%Y") + return dt.date() + + # ISO datetime path: interpret as UTC and convert to target timezone. + # `utc=True` yields tz-aware timestamps. + ts = pd.to_datetime(v, utc=True, errors="raise") + if tz: + ts = ts.tz_convert(tz) + return ts.date() + + +def farm_local_date_range_from_config(farm: dict) -> FarmLocalDateRange: + """ + Compute (start_date, end_date) for filename naming from `wind_farms_config.json`, + using the farm timezone and interpreting configured date values as local. + """ + tz_name = farm.get("report_config", {}).get("timezone") + tz = ZoneInfo(tz_name) if tz_name else None + + date_range_cfg = farm.get("api_params", {}).get("date_range", {}) + method = str(date_range_cfg.get("method", "auto")).lower() + + if method == "manual": + start_val = date_range_cfg.get("start_date") + end_val = date_range_cfg.get("end_date") + if not start_val or not end_val: + raise ValueError("Manual date_range requires start_date and end_date") + start_dt = _parse_date_value_local(str(start_val), tz) + end_dt = _parse_date_value_local(str(end_val), tz) + return FarmLocalDateRange( + start_date_yyyy_mm_dd=start_dt.strftime("%Y-%m-%d"), + end_date_yyyy_mm_dd=end_dt.strftime("%Y-%m-%d"), + ) + + # Auto mode: compute a local date range based on `query_range.method`. + query_range_cfg = date_range_cfg.get("query_range", {}) or {} + query_method = str(query_range_cfg.get("method", "current_month")).lower() + + now_local = datetime.now(tz).date() if tz else datetime.now().date() + + if query_method == "current_month": + start_dt = date(now_local.year, now_local.month, 1) + end_dt = now_local + elif query_method == "last_month": + if now_local.month == 1: + prev_year = now_local.year - 1 + prev_month = 12 + else: + prev_year = now_local.year + prev_month = now_local.month - 1 + start_dt = date(prev_year, prev_month, 1) + # Last day of previous month: + first_of_next_month = date(prev_year, prev_month, 1) + timedelta(days=32) + end_dt = date(first_of_next_month.year, first_of_next_month.month, 1) - timedelta(days=1) + elif query_method == "days_back": + days = int(query_range_cfg.get("days", 30)) + start_dt = now_local - timedelta(days=days) + end_dt = now_local + elif query_method == "custom": + start_val = query_range_cfg.get("start_date") + end_val = query_range_cfg.get("end_date") + if not start_val or not end_val: + raise ValueError("Auto date_range.custom requires query_range.start_date and end_date") + start_dt = _parse_date_value_local(str(start_val), tz) + end_dt = _parse_date_value_local(str(end_val), tz) + else: + # Fallback: treat as current_month. + start_dt = date(now_local.year, now_local.month, 1) + end_dt = now_local + + return FarmLocalDateRange( + start_date_yyyy_mm_dd=start_dt.strftime("%Y-%m-%d"), + end_date_yyyy_mm_dd=end_dt.strftime("%Y-%m-%d"), + ) + diff --git a/src/reporting/gemini_commentary.py b/src/reporting/gemini_commentary.py new file mode 100644 index 0000000..1d51aba --- /dev/null +++ b/src/reporting/gemini_commentary.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import os +from typing import Any + +from src.utils import get_risk_definition_by_fixed_intervals + + +def build_gemini_prompt(context: dict[str, Any]) -> str: + analysis_period = context.get("analysis_period", "N/A") + analysis_radius_km = context.get("analysis_radius_km", None) + total_events = context.get("total_events", None) + total_lightning_per_km2 = context.get("total_lightning_per_km2", None) + turbine_count = context.get("turbine_count", None) + is_single_turbine_report = context.get("is_single_turbine_report", None) + + top_rings = context.get("top_rings", []) # list of (ring_name, total, cg_count, ic_count) + max_risk_log = context.get("max_risk_log", None) + max_risk_definition = context.get("max_risk_definition", None) + top_turbine_name = context.get("top_turbine_name", "N/A") + top_turbine_risk_log = context.get("top_turbine_risk_log", None) + + storm_summary = context.get("storm_summary") + storm_over_turbine = context.get("storm_over_turbine") + storm_near_turbine_count = context.get("storm_near_turbine_count") + storm_closest_distance_km = context.get("storm_closest_distance_km") + storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0) + + ring_lines: list[str] = [] + for ring in top_rings[:3]: + try: + ring_name, total, cg_count, ic_count = ring + except Exception: + continue + ring_lines.append(f"- {ring_name}: total={total}, cloud-to-ground={cg_count}, intercloud={ic_count}") + + storm_lines: list[str] = [] + if isinstance(storm_summary, dict) and storm_summary: + total_cells = storm_summary.get("total_cells", 0) + severity_counts = storm_summary.get("severity_counts", {}) or {} + storm_lines.append(f"- total_cells={total_cells}") + for severity, count in severity_counts.items(): + storm_lines.append(f"- {severity}_cells={count}") + + return ( + "You are generating a single neutral, factual commentary paragraph for a lightning activity report.\n" + "Write exactly 3-4 sentences.\n" + "Do not invent any numbers. Only use the values provided.\n" + "\n" + "Risk explanation requirements (must be reflected in the paragraph):\n" + "- Turbine risk increases for cloud-to-ground strikes with larger current magnitude.\n" + "- Turbine risk decays exponentially with increasing distance from the turbine.\n" + "- The turbine risk score is the sum of per-strike contributions (then log-transformed for visualization/heatmaps/tables).\n" + "\n" + "Context:\n" + f"- analysis_period: {analysis_period}\n" + f"- analysis_radius_km: {analysis_radius_km}\n" + f"- total_events: {total_events}\n" + f"- total_lightning_per_km2: {total_lightning_per_km2}\n" + f"- turbine_count: {turbine_count}\n" + f"- is_single_turbine_report: {is_single_turbine_report}\n" + f"- top_rings:\n{chr(10).join(ring_lines) if ring_lines else '- N/A'}\n" + f"- max_risk_log: {max_risk_log}\n" + f"- max_risk_definition: {max_risk_definition}\n" + f"- top_turbine_name: {top_turbine_name}\n" + f"- top_turbine_risk_log: {top_turbine_risk_log}\n" + f"- storm_over_turbine: {storm_over_turbine}\n" + f"- storm_near_turbine_count: {storm_near_turbine_count}\n" + f"- storm_closest_distance_km: {storm_closest_distance_km}\n" + f"- storm_over_threshold_km: {storm_over_threshold_km}\n" + + (f"\n- storm_summary:\n{chr(10).join(storm_lines)}" if storm_lines else "\n- storm_summary: not available") + + "\n\n" + "Requirements for the paragraph:\n" + "- Mention one key takeaway from the ring distribution (e.g., where totals are highest).\n" + "- Mention the overall lightning density (events/km²).\n" + "- If is_single_turbine_report is true: start the sentence mentioning the highest-risk turbine as \"For {top_turbine_name}, ...\"; avoid wording like \"Within the analyzed area\" and avoid verbs like \"was identified\".\n" + "- If is_single_turbine_report is false: you may use wording like \"Within the analyzed area, {top_turbine_name} was identified ...\".\n" + "- Mention the specific turbine name with the highest risk score (top_turbine_name) verbatim.\n" + "- Mention the risk category for that turbine using max_risk_definition.\n" + "- Do not refer only to the category; always associate the risk with top_turbine_name.\n" + "- Mention storm-cell interaction with the turbine when storm information is available:\n" + " - If storm_over_turbine is true: say that storm cells were very close to/over the turbine (based on centroid distance <= storm_over_threshold_km).\n" + " - If storm_over_turbine is false and storm_closest_distance_km is provided: say the closest storm cell centroid came within storm_closest_distance_km km of the turbine.\n" + "- If storm_summary is available, mention total storm cells and at least one severity count.\n" + "- Round numeric values as follows (use the rounded values you are given, avoid long decimals):\n" + " - lightning density to 3 decimals (events/km²)\n" + " - log-transformed risk score(s) to 2 decimals\n" + " - distances (analysis_radius_km, storm_closest_distance_km) to 1 decimal (km)\n" + " - counts to integers\n" + "- Keep tone analytic and non-alarmist.\n" + "\n" + "Output:\n" + "One paragraph only (no bullet points, no headings)." + ) + + +def fallback_commentary(context: dict[str, Any]) -> str: + analysis_period = context.get("analysis_period", "N/A") + analysis_radius_km = context.get("analysis_radius_km", None) + total_events = context.get("total_events", None) + total_lightning_per_km2 = context.get("total_lightning_per_km2", None) + turbine_count = context.get("turbine_count", None) + is_single_turbine_report = context.get("is_single_turbine_report", None) + top_rings = context.get("top_rings", []) + max_risk_definition = context.get("max_risk_definition", "N/A") + top_turbine_name = context.get("top_turbine_name", "N/A") + top_turbine_risk_log = context.get("top_turbine_risk_log", None) + storm_over_turbine = context.get("storm_over_turbine") + storm_closest_distance_km = context.get("storm_closest_distance_km") + storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0) + storm_near_turbine_count = context.get("storm_near_turbine_count") + + outermost_ring = top_rings[-1] if top_rings else None + best_ring = top_rings[0] if top_rings else None + + def _format_ring(ring: Any) -> str: + try: + ring_name, total, cg_count, ic_count = ring + return f"{ring_name} (total={total}, cloud-to-ground={cg_count}, intercloud={ic_count})" + except Exception: + return "N/A" + + best_ring_txt = _format_ring(best_ring) + outer_ring_txt = _format_ring(outermost_ring) + + storm_summary = context.get("storm_summary") or {} + storm_line = "" + if isinstance(storm_summary, dict) and storm_summary: + total_cells = storm_summary.get("total_cells", 0) + severity_counts = storm_summary.get("severity_counts", {}) or {} + if severity_counts: + # Pick max severity to mention + severity = max(severity_counts.items(), key=lambda kv: kv[1])[0] + count = severity_counts.get(severity, 0) + storm_line = f"Storm data indicates {total_cells} storm cells, with the highest share in {severity} ({count} cells)." + else: + storm_line = f"Storm data indicates {total_cells} storm cells." + + density_txt = ( + f"{total_lightning_per_km2:.3f} events/km²" if isinstance(total_lightning_per_km2, (int, float)) else str(total_lightning_per_km2) + ) + + radius_txt = f"within {analysis_radius_km:.1f} km" if isinstance(analysis_radius_km, (int, float)) else "" + events_txt = f"{total_events} total lightning events" if total_events is not None else "N/A" + + paragraph_intro = ( + f"For {analysis_period}, the dataset contains {events_txt} {radius_txt}, corresponding to an overall lightning density of {density_txt}. " + f"The largest contributions are concentrated in {best_ring_txt}, with additional activity also present in {outer_ring_txt}. " + ) + + turbine_sentence = ( + f"For {top_turbine_name}, the log-transformed risk score is the highest in this report and falls in the {max_risk_definition} category. " + if is_single_turbine_report + else f"Within the analyzed area, the turbine with the highest log-transformed risk score is {top_turbine_name}, which falls in the {max_risk_definition} category. " + ) + + method_sentence = ( + f"This indicates the turbine was exposed to a combination of closer cloud-to-ground strikes and stronger current magnitudes. " + f"In the risk model, each cloud-to-ground strike contributes more when it is near the turbine and when |I| is larger, and contributions decrease exponentially with distance; the turbine risk score is the sum over all included strikes (with a log transform used for visualization). " + ) + + if storm_over_turbine: + storm_interaction_sentence = ( + f"Storm interaction: storm-cell centroids came within {storm_over_threshold_km:.1f} km of the turbine (count={storm_near_turbine_count}). " + ) + elif isinstance(storm_closest_distance_km, (int, float)): + storm_interaction_sentence = ( + f"Storm interaction: the closest storm-cell centroid came within {storm_closest_distance_km:.1f} km of the turbine. " + ) + else: + storm_interaction_sentence = "" + + storm_severity_sentence = storm_line if storm_line else "Storm severity distribution is not available for this report." + paragraph = (paragraph_intro + turbine_sentence + method_sentence + storm_interaction_sentence + storm_severity_sentence).strip() + return paragraph + + +def generate_gemini_paragraph(context: dict[str, Any], api_key: str | None = None) -> str: + api_key_final = api_key or os.getenv("GEMINI_API_KEY") + if not api_key_final: + return fallback_commentary(context) + + model_name = os.getenv("GEMINI_MODEL", "gemini-1.5-flash") + + prompt = build_gemini_prompt(context) + + try: + import google.generativeai as genai + + genai.configure(api_key=api_key_final) + model = genai.GenerativeModel(model_name) + + # Keep output short and deterministic + resp = model.generate_content( + prompt, + generation_config={ + "temperature": 0.2, + "max_output_tokens": 220, + }, + ) + + text = getattr(resp, "text", None) or "" + text = str(text).strip() + if not text: + return fallback_commentary(context) + return text + except Exception: + return fallback_commentary(context) + diff --git a/src/reporting/precompute.py b/src/reporting/precompute.py new file mode 100644 index 0000000..8b1ad74 --- /dev/null +++ b/src/reporting/precompute.py @@ -0,0 +1,86 @@ +import numpy as np +import pandas as pd +from typing import List, Tuple, Optional, Dict, Any +from src.analysis.geospatial import haversine_distance_vectorized +from src.utils import format_datetime_ddmmyyyy_hhmmss + +def build_lightning_table_rows( + centroid_lat: float, + centroid_lng: float, + lightning_df: pd.DataFrame, + distance_rings_m: List[int], + ring_colors: List[str], +) -> Tuple[List[List[str]], List[str]]: + rows: List[List[str]] = [] + row_colors: List[str] = [] + + if len(lightning_df) == 0: + return rows, row_colors + + outermost_km = max(distance_rings_m) / 1000.0 + rings_km = np.array(distance_rings_m, dtype=float) / 1000.0 + + dists_km = haversine_distance_vectorized( + centroid_lat, centroid_lng, + lightning_df['lat'].values, lightning_df['lng'].values, + ) / 1000.0 + + for i, rec in enumerate(lightning_df.itertuples(index=False)): + proximity = dists_km[i] + if proximity > outermost_km: + continue + + ring_idx = int(np.searchsorted(rings_km, proximity, side='left')) + if ring_idx >= len(rings_km): + continue + color = ring_colors[ring_idx] + + try: + dt_val = rec.local_time if not isinstance(rec.local_time, str) else pd.to_datetime(str(rec.local_time)[:19]) + local_time = format_datetime_ddmmyyyy_hhmmss(pd.to_datetime(dt_val)) + except Exception: + local_time = str(getattr(rec, 'local_time', ''))[:19] + + lightning_type = "cloud-to-ground" if str(rec.p_type) == '0' else "intercloud" + rows.append([ + "", + local_time, + f"{rec.lat:.5f}", + f"{rec.lng:.5f}", + str(rec.current), + str(getattr(rec, 'height', '')), + lightning_type, + f"{proximity:.2f}" + ]) + row_colors.append(color) + + return rows, row_colors + + +def precompute_group_distances_and_rings( + centroid_lat: float, + centroid_lng: float, + lightning_df: pd.DataFrame, + distance_rings_m: List[int], +) -> Dict[str, Any]: + if len(lightning_df) == 0: + return { + 'dists_km': np.array([], dtype=float), + 'ring_idx': np.array([], dtype=int), + 'mask_within': np.array([], dtype=bool), + } + rings_km = np.array(distance_rings_m, dtype=float) / 1000.0 + dists_km = haversine_distance_vectorized( + centroid_lat, centroid_lng, + lightning_df['lat'].values, lightning_df['lng'].values, + ) / 1000.0 + mask_within = dists_km <= rings_km[-1] + # For values beyond last ring, clamp index to len(rings) + ring_idx = np.searchsorted(rings_km, dists_km, side='left') + return { + 'dists_km': dists_km, + 'ring_idx': ring_idx, + 'mask_within': mask_within, + } + + diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..df550d1 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,480 @@ +import json +import pandas as pd +import numpy as np +from datetime import datetime +from typing import Union, Dict, Any, Optional, List +from zoneinfo import ZoneInfo +import logging +import re + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def format_date_ddmmyyyy(dt: datetime) -> str: + return dt.strftime('%d-%m-%Y') + +def format_datetime_ddmmyyyy_hhmmss(dt: datetime) -> str: + return dt.strftime('%d-%m-%Y %H:%M:%S') + +def format_datetime_ddmmyyyy_hhmm(dt: datetime) -> str: + return dt.strftime('%d-%m-%Y %H:%M') + +def get_utc_offset_label(timezone_name: Optional[str]) -> Optional[str]: + if not timezone_name: + return None + try: + tz = ZoneInfo(timezone_name) + dt = datetime.now(tz) + offset = dt.utcoffset() + if offset is None: + return None + total_seconds = int(offset.total_seconds()) + hours = total_seconds // 3600 + if hours >= 0: + return f"UTC+{hours}" + return f"UTC{hours}" + except Exception: + return None + +def now_in_timezone(timezone_name: Optional[str]) -> datetime: + if not timezone_name: + return datetime.now() + try: + return datetime.now(ZoneInfo(timezone_name)) + except Exception: + return datetime.now() + +def format_datetime_to_local_display(value: Optional[str], timezone_name: Optional[str] = None) -> str: + if not value or str(value).strip() == '' or str(value).strip().upper() == 'N/A': + return 'N/A' + s = str(value).strip() + try: + ts = pd.to_datetime(s, utc=True) + if timezone_name: + ts = ts.tz_convert(ZoneInfo(timezone_name)).tz_localize(None) + else: + ts = ts.to_pydatetime().replace(tzinfo=None) + dt = ts.to_pydatetime() if hasattr(ts, 'to_pydatetime') else ts + return dt.strftime('%d-%m-%Y %H:%M') + except Exception: + return s[:19] if len(s) >= 19 else s + +def parse_period_string_to_datetime(value: Optional[str]) -> Optional[datetime]: + if value is None: + return None + value_str = str(value).strip() + if not value_str: + return None + try: + if re.fullmatch(r"\d{2}-\d{2}-\d{4}", value_str): + return datetime.strptime(value_str, '%d-%m-%Y') + if re.match(r"\d{2}-\d{2}-\d{4}\s+\d", value_str): + try: + return datetime.strptime(value_str[:19], '%d-%m-%Y %H:%M:%S') + except ValueError: + try: + return datetime.strptime(value_str[:16], '%d-%m-%Y %H:%M') + except ValueError: + pass + ts = pd.to_datetime(value_str, errors='raise') + if isinstance(ts, pd.Timestamp): + if ts.tzinfo is not None: + ts = ts.tz_convert('UTC').tz_localize(None) + return ts.to_pydatetime() + except Exception as e: + logger.debug(f"parse_period_string_to_datetime failed for {value_str}: {e}") + return None + return None + +def normalize_local_time_to_timezone( + df: pd.DataFrame, + column: str, + timezone_name: Optional[str], +) -> pd.DataFrame: + if len(df) == 0 or not timezone_name: + return df + tz = ZoneInfo(timezone_name) + df = df.copy() + df[column] = pd.to_datetime(df[column], utc=True, errors='coerce') + df = df[~df[column].isna()] + if len(df) == 0: + return df + df[column] = df[column].dt.tz_convert(tz).dt.tz_localize(None) + return df + +def format_period_display_for_report(start_value: Optional[str], end_value: Optional[str]) -> tuple[str, str]: + def _format_one(val: Optional[str]) -> str: + if not val or not str(val).strip(): + return "" + s = str(val).strip() + try: + if re.fullmatch(r"\d{2}-\d{2}-\d{4}", s): + return s + if "T" in s or "Z" in s: + ts = pd.to_datetime(s, utc=True) + local_dt = ts.to_pydatetime().astimezone(None) + return local_dt.strftime('%d-%m-%Y %H:%M') + ts = pd.to_datetime(s, errors='raise') + if isinstance(ts, pd.Timestamp): + if ts.tzinfo is not None: + ts = ts.tz_localize(None) + dt = ts.to_pydatetime() + return dt.strftime('%d-%m-%Y %H:%M') + return s + except Exception: + return s + start_display = _format_one(start_value) if start_value else "" + end_display = _format_one(end_value) if end_value else "" + return start_display, end_display + +def get_grouping_radius_m() -> int: + from .config import config + rings = config.distance_rings or [] + grouping = config.grouping_params or {} + max_distance_m = grouping.get('max_distance_m') + if isinstance(max_distance_m, (int, float)) and max_distance_m > 0: + return int(max_distance_m) + ring_index = grouping.get('distance_ring_index', 2) + if isinstance(ring_index, int) and 0 <= ring_index < len(rings): + return int(rings[ring_index]) + return int(rings[2] if len(rings) > 2 else (max(rings) if rings else 0)) + +def get_analysis_radius_m() -> int: + from .config import config + rings = config.distance_rings or [] + grouping = config.grouping_params or {} + ring_index = grouping.get('distance_ring_index') + if isinstance(ring_index, int) and 0 <= ring_index < len(rings): + return int(rings[ring_index]) + return int(max(rings) if rings else 0) + +def get_turbine_color_by_fixed_intervals(risk_log_value: float) -> str: + """ + Get turbine color based on fixed risk score intervals. + Uses consistent color coding across all groups and tables. + + Args: + risk_log_value: Log-transformed risk score + + Returns: + Color string for the turbine + """ + # Define fixed risk intervals and corresponding colors + # Using the new color palette: F94144, F3722C, F8961E, F9C74F, 90BE6D, 43AA8B, 577590 + if risk_log_value < 0.1: + return '#577590' + elif risk_log_value < 0.2: + return '#43AA8B' + elif risk_log_value < 0.4: + return '#90BE6D' + elif risk_log_value < 0.6: + return '#F9C74F' + elif risk_log_value < 0.8: + return '#F8961E' + elif risk_log_value < 1.0: + return '#F3722C' + elif risk_log_value < 1.2: + return '#F94144' + elif risk_log_value < 1.4: + return '#D32F2F' + else: + return '#B71C1C' + +def get_risk_definition_by_fixed_intervals(risk_log_value: float) -> str: + if risk_log_value < 0.1: + return 'Very Low Risk' + elif risk_log_value < 0.2: + return 'Low Risk' + elif risk_log_value < 0.4: + return 'Med-Low Risk' + elif risk_log_value < 0.6: + return 'Medium Risk' + elif risk_log_value < 0.8: + return 'Med-High Risk' + elif risk_log_value < 1.0: + return 'High Risk' + elif risk_log_value < 1.2: + return 'Very High Risk' + elif risk_log_value < 1.4: + return 'Critical Risk' + else: + return 'Maximum Risk' + +def get_turbine_colors_by_fixed_intervals(risk_log_values: List[float]) -> List[str]: + """ + Get turbine colors for a list of risk scores based on fixed intervals. + + Args: + risk_log_values: List of log-transformed risk scores + + Returns: + List of color strings for the turbines + """ + return [get_turbine_color_by_fixed_intervals(risk_log) for risk_log in risk_log_values] + +def safe_datetime_conversion(time_str: str) -> Optional[datetime]: + """ + Safely convert string to datetime with error handling. + + Args: + time_str: String representation of datetime + + Returns: + datetime object or None if conversion fails + """ + if not time_str or pd.isna(time_str): + return None + + # Try different datetime formats + formats = [ + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%d %H:%M:%S.%f', + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%dT%H:%M:%S.%f', + '%Y-%m-%d' + ] + + for fmt in formats: + try: + return datetime.strptime(time_str[:19], fmt) + except ValueError: + continue + + # Try pandas parsing as fallback + parsed = None + try: + parsed = pd.to_datetime(time_str, errors='coerce') + except Exception: + parsed = None + if isinstance(parsed, pd.Timestamp) and not pd.isna(parsed): + return parsed.to_pydatetime() + + if isinstance(time_str, datetime): + return time_str + + logger.error(f"Failed to convert datetime: {time_str}") + return None + +def load_json_data(file_path: str) -> Dict[str, Any]: + """ + Generic JSON loader with error handling. + + Args: + file_path: Path to JSON file + + Returns: + Dictionary containing JSON data + + Raises: + FileNotFoundError: If file doesn't exist + json.JSONDecodeError: If JSON is invalid + """ + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + logger.info(f"Successfully loaded JSON data from {file_path}") + return data + except FileNotFoundError: + logger.error(f"File not found: {file_path}") + raise + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in {file_path}: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error loading {file_path}: {e}") + raise + +def filter_lightning_data_by_date_range(lightning_df: pd.DataFrame, start_date: Optional[str] = None, end_date: Optional[str] = None) -> pd.DataFrame: + """ + Filter lightning data by date range. + + Args: + lightning_df: DataFrame containing lightning data with 'local_time' column + start_date: Start date in format 'DD-MM-YYYY' or None for no filtering + end_date: End date in format 'DD-MM-YYYY' or None for no filtering + + Returns: + Filtered DataFrame containing only lightning data within the specified date range + """ + if start_date is None and end_date is None: + return lightning_df + + def _parse_flexible_datetime(value: Optional[str], is_end: bool = False) -> Optional[datetime]: + if value is None: + return None + + value_str = str(value).strip() + if not value_str: + return None + + try: + if re.fullmatch(r"\d{2}-\d{2}-\d{4}", value_str): + dt = datetime.strptime(value_str, '%d-%m-%Y') + if is_end: + dt = dt.replace(hour=23, minute=59, second=59) + return dt + + ts = pd.to_datetime(value_str, errors='raise') + if isinstance(ts, pd.Timestamp): + if ts.tzinfo is not None: + ts = ts.tz_convert('UTC').tz_localize(None) + return ts.to_pydatetime() + except Exception as e: + logger.error(f"Invalid datetime value: {value_str}. Error: {e}") + return None + + return None + + df = lightning_df.copy() + if df['local_time'].dtype == 'object': + df['local_time'] = pd.to_datetime(df['local_time']) + + if df['local_time'].dt.tz is not None: + df['local_time'] = df['local_time'].dt.tz_localize(None) + + start_dt = _parse_flexible_datetime(start_date, is_end=False) + end_dt = _parse_flexible_datetime(end_date, is_end=True) + + if start_date and start_dt is None: + logger.error(f"Invalid start_date value: {start_date}. Expected 'DD-MM-YYYY' or ISO datetime string.") + return lightning_df + + if end_date and end_dt is None: + logger.error(f"Invalid end_date value: {end_date}. Expected 'DD-MM-YYYY' or ISO datetime string.") + return lightning_df + + # Apply date filtering + if start_dt and end_dt: + mask = (df['local_time'] >= start_dt) & (df['local_time'] <= end_dt) + filtered_df = df[mask] + logger.info(f"Filtered lightning data from {len(lightning_df)} to {len(filtered_df)} records ({start_date} to {end_date})") + return filtered_df + + if start_dt: + mask = df['local_time'] >= start_dt + filtered_df = df[mask] + logger.info(f"Filtered lightning data from {len(lightning_df)} to {len(filtered_df)} records (from {start_date})") + return filtered_df + + if end_dt: + mask = df['local_time'] <= end_dt + filtered_df = df[mask] + logger.info(f"Filtered lightning data from {len(lightning_df)} to {len(filtered_df)} records (until {end_date})") + return filtered_df + + return df + +def validate_lightning_data(df: pd.DataFrame) -> bool: + """ + Validate lightning data structure and content. + + Args: + df: Lightning DataFrame + + Returns: + True if valid, False otherwise + """ + required_columns = ['lat', 'lng', 'current', 'p_type', 'local_time'] + + # Handle empty dataset gracefully + if len(df) == 0: + logger.warning("Lightning dataset is empty - this is acceptable for analysis") + return True + + # Check required columns + missing_columns = [col for col in required_columns if col not in df.columns] + if missing_columns: + logger.error(f"Missing required columns: {missing_columns}") + return False + + # Check data types + if not pd.api.types.is_numeric_dtype(df['lat']): + logger.error("Latitude column must be numeric") + return False + + if not pd.api.types.is_numeric_dtype(df['lng']): + logger.error("Longitude column must be numeric") + return False + + if not pd.api.types.is_numeric_dtype(df['current']): + logger.error("Current column must be numeric") + return False + + # Check coordinate ranges + if not (df['lat'].between(-90, 90).all()): + logger.error("Latitude values must be between -90 and 90") + return False + + if not (df['lng'].between(-180, 180).all()): + logger.error("Longitude values must be between -180 and 180") + return False + + # Check p_type values + valid_p_types = ['0', '1', 0, 1] + invalid_p_types = df[~df['p_type'].astype(str).isin(['0', '1'])] + if len(invalid_p_types) > 0: + logger.warning(f"Found {len(invalid_p_types)} invalid p_type values") + + logger.info(f"Lightning data validation passed: {len(df)} records") + return True + +def validate_turbine_data(df: pd.DataFrame) -> bool: + """ + Validate turbine data structure and content. + + Args: + df: Turbine DataFrame + + Returns: + True if valid, False otherwise + """ + required_columns = ['lat', 'lng', 'name'] + + # Check required columns + missing_columns = [col for col in required_columns if col not in df.columns] + if missing_columns: + logger.error(f"Missing required columns: {missing_columns}") + return False + + # Check data types + if not pd.api.types.is_numeric_dtype(df['lat']): + logger.error("Latitude column must be numeric") + return False + + if not pd.api.types.is_numeric_dtype(df['lng']): + logger.error("Longitude column must be numeric") + return False + + # Check coordinate ranges + if not (df['lat'].between(-90, 90).all()): + logger.error("Latitude values must be between -90 and 90") + return False + + if not (df['lng'].between(-180, 180).all()): + logger.error("Longitude values must be between -180 and 180") + return False + + logger.info(f"Turbine data validation passed: {len(df)} records") + return True + +def ensure_datetime_column(df: pd.DataFrame, column: str) -> pd.DataFrame: + """ + Ensure a column contains datetime objects. + + Args: + df: DataFrame + column: Column name to convert + + Returns: + DataFrame with converted datetime column + """ + # Handle empty DataFrame + if len(df) == 0: + return df + + if df[column].dtype == 'object': + df = df.copy() + df[column] = pd.to_datetime(df[column], errors='coerce') + logger.info(f"Converted {column} to datetime") + return df \ No newline at end of file diff --git a/src/visualization/maps.py b/src/visualization/maps.py new file mode 100644 index 0000000..243547b --- /dev/null +++ b/src/visualization/maps.py @@ -0,0 +1,1111 @@ +import plotly.graph_objects as go +import plotly.express as px +import numpy as np +from src.analysis.geospatial import create_circle_points, haversine_distance +from src.config import config +import pandas as pd + +def plot_turbine_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure: + turbine_lat = turbine_row['lat'] + turbine_lon = turbine_row['lng'] + fig = go.Figure() + # Plot all lightnings + lightning_colors = [] + for _, lightning in lightning_df.iterrows(): + d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) + color = 'gray' + for ring, ring_color in zip(config.distance_rings, config.ring_colors): + if d <= ring: + color = ring_color + break + lightning_colors.append(color) + lightning_sizes = np.clip(lightning_df['current_abs'] / 1500, 3, 12) + fig.add_trace(go.Scattermapbox( + lat=lightning_df['lat'], + lon=lightning_df['lng'], + mode='markers', + marker=dict(size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3), + name='Lightning Strikes', + showlegend=True + )) + + # Add distance circles + for radius, color in zip(config.distance_rings, config.ring_colors): + circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) + fig.add_trace(go.Scattermapbox( + lat=circle_lats, + lon=circle_lons, + mode='lines', + line=dict(color=color, width=2), + opacity=0.6, + name=f'{radius/1000:.1f}km Distance Ring', + showlegend=True + )) + # Add turbines colored by risk using fixed intervals + if 'risk_log' in turbine_df.columns: + from src.utils import get_turbine_colors_by_fixed_intervals + turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df['risk_log'].tolist()) + else: + turbine_colors = ['red'] * len(turbine_df) + fig.add_trace(go.Scattermapbox( + lat=turbine_df['lat'], + lon=turbine_df['lng'], + mode='markers+text', + marker=dict(size=16, color=turbine_colors, symbol='circle', opacity=1), + text=['T'] * len(turbine_df), + textfont=dict(size=10, color='black'), + textposition='middle center', + name='Wind Turbines', + showlegend=True + )) + # Calculate zoom level based on the largest distance ring + max_radius_m = max(config.distance_rings) + + # Convert meters to degrees (approximate) + # 1 degree latitude ≈ 111,000 meters + # 1 degree longitude ≈ 111,000 * cos(latitude) meters + lat_degrees = max_radius_m / 111000 + lon_degrees = max_radius_m / (111000 * np.cos(np.radians(turbine_lat))) + + # Calculate the required map span to show the largest circle + # We want the circle to almost touch the top and bottom edges + # Map aspect ratio is roughly 4:3 (width:height) + # We need to account for the circle diameter (2 * radius) plus minimal padding + + # Calculate the required span in degrees + required_lat_span = 2 * lat_degrees * 1.1 # 10% padding + required_lon_span = 2 * lon_degrees * 1.1 # 10% padding + + # Use the larger span to ensure the circle fits + required_span = max(required_lat_span, required_lon_span) + + # Calculate zoom level based on required span + # Mapbox zoom levels: each level doubles the scale + # At zoom 0: 360 degrees longitude spans the map + # At zoom 1: 180 degrees longitude spans the map + # At zoom 2: 90 degrees longitude spans the map + # Formula: zoom = log2(360 / required_span) + + import math + zoom_level = math.log2(360 / required_span) + + # Clamp zoom level to reasonable bounds + zoom_level = max(8, min(15, zoom_level)) + + fig.update_layout( + mapbox=dict( + style='carto-positron', + center=dict(lat=turbine_lat, lon=turbine_lon), + zoom=zoom_level + ), + margin=dict(l=0, r=0, t=0, b=0), + showlegend=True, + legend=dict( + x=0.02, + y=0.98, + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='black', + borderwidth=1 + ) + ) + return fig + +def plot_intercloud_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure: + """Create a map showing only intercloud lightning strikes.""" + turbine_lat = turbine_row['lat'] + turbine_lon = turbine_row['lng'] + fig = go.Figure() + + # Filter for intercloud lightning only (p_type != '0') + ic_mask = (lightning_df['p_type'].astype(str) != '0') + ic_lightning_df = lightning_df[ic_mask] + + if len(ic_lightning_df) > 0: + # Plot intercloud lightnings + lightning_colors = [] + for _, lightning in ic_lightning_df.iterrows(): + d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) + color = 'gray' + for ring, ring_color in zip(config.distance_rings, config.ring_colors): + if d <= ring: + color = ring_color + break + lightning_colors.append(color) + + lightning_sizes = np.clip(ic_lightning_df['current_abs'] / 1500, 3, 12) + fig.add_trace(go.Scattermapbox( + lat=ic_lightning_df['lat'], + lon=ic_lightning_df['lng'], + mode='markers', + marker=dict(size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3), + name='Intercloud Lightning', + showlegend=True + )) + + # Add distance circles + for radius, color in zip(config.distance_rings, config.ring_colors): + circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) + fig.add_trace(go.Scattermapbox( + lat=circle_lats, + lon=circle_lons, + mode='lines', + line=dict(color=color, width=2), + opacity=0.6, + name=f'{radius/1000:.1f}km Distance Ring', + showlegend=True + )) + + # Add turbines colored by risk + if 'risk_log' in turbine_df.columns: + from src.utils import get_turbine_colors_by_fixed_intervals + turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df["risk_log"].tolist()) + norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) + else: + turbine_colors = ['red'] * len(turbine_df) + + fig.add_trace(go.Scattermapbox( + lat=turbine_df['lat'], + lon=turbine_df['lng'], + mode='markers+text', + marker=dict(size=16, color=turbine_colors, symbol='circle', opacity=1), + text=['T'] * len(turbine_df), + textfont=dict(size=10, color='black'), + textposition='middle center', + name='Wind Turbines', + showlegend=True + )) + + # Calculate zoom level based on the largest distance ring + max_radius_m = max(config.distance_rings) + + # Convert meters to degrees (approximate) + lat_degrees = max_radius_m / 111000 + lon_degrees = max_radius_m / (111000 * np.cos(np.radians(turbine_lat))) + + # Calculate the required map span to show the largest circle + required_lat_span = 2 * lat_degrees * 1.1 + required_lon_span = 2 * lon_degrees * 1.1 + required_span = max(required_lat_span, required_lon_span) + + # Calculate zoom level based on required span + import math + zoom_level = math.log2(360 / required_span) + + # Clamp zoom level to reasonable bounds + zoom_level = max(8, min(15, zoom_level)) + + fig.update_layout( + mapbox=dict( + style='carto-positron', + center=dict(lat=turbine_lat, lon=turbine_lon), + zoom=zoom_level + ), + margin=dict(l=0, r=0, t=0, b=0), + showlegend=True, + legend=dict( + x=0.02, + y=0.98, + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='black', + borderwidth=1 + ) + ) + return fig + +def plot_cloud_to_ground_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure: + """Create a map showing only cloud-to-ground lightning strikes.""" + turbine_lat = turbine_row['lat'] + turbine_lon = turbine_row['lng'] + fig = go.Figure() + + # Filter for cloud-to-ground lightning only (p_type == '0') + cg_mask = (lightning_df['p_type'].astype(str) == '0') + cg_lightning_df = lightning_df[cg_mask] + + if len(cg_lightning_df) > 0: + # Plot cloud-to-ground lightnings + lightning_colors = [] + for _, lightning in cg_lightning_df.iterrows(): + d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) + color = 'gray' + for ring, ring_color in zip(config.distance_rings, config.ring_colors): + if d <= ring: + color = ring_color + break + lightning_colors.append(color) + + lightning_sizes = np.clip(cg_lightning_df['current_abs'] / 1500, 3, 12) + fig.add_trace(go.Scattermapbox( + lat=cg_lightning_df['lat'], + lon=cg_lightning_df['lng'], + mode='markers', + marker=dict(size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3), + name='Cloud-to-Ground Lightning', + showlegend=True + )) + + # Add distance circles + for radius, color in zip(config.distance_rings, config.ring_colors): + circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) + fig.add_trace(go.Scattermapbox( + lat=circle_lats, + lon=circle_lons, + mode='lines', + line=dict(color=color, width=2), + opacity=0.6, + name=f'{radius/1000:.1f}km Distance Ring', + showlegend=True + )) + + # Add turbines colored by risk + if 'risk_log' in turbine_df.columns: + from src.utils import get_turbine_colors_by_fixed_intervals + turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df["risk_log"].tolist()) + norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) + else: + turbine_colors = ['red'] * len(turbine_df) + + fig.add_trace(go.Scattermapbox( + lat=turbine_df['lat'], + lon=turbine_df['lng'], + mode='markers+text', + marker=dict(size=16, color=turbine_colors, symbol='circle', opacity=1), + text=['T'] * len(turbine_df), + textfont=dict(size=10, color='black'), + textposition='middle center', + name='Wind Turbines', + showlegend=True + )) + + # Calculate zoom level based on the largest distance ring + max_radius_m = max(config.distance_rings) + + # Convert meters to degrees (approximate) + lat_degrees = max_radius_m / 111000 + lon_degrees = max_radius_m / (111000 * np.cos(np.radians(turbine_lat))) + + # Calculate the required map span to show the largest circle + required_lat_span = 2 * lat_degrees * 1.1 + required_lon_span = 2 * lon_degrees * 1.1 + required_span = max(required_lat_span, required_lon_span) + + # Calculate zoom level based on required span + import math + zoom_level = math.log2(360 / required_span) + + # Clamp zoom level to reasonable bounds + zoom_level = max(8, min(15, zoom_level)) + + fig.update_layout( + mapbox=dict( + style='carto-positron', + center=dict(lat=turbine_lat, lon=turbine_lon), + zoom=zoom_level + ), + margin=dict(l=0, r=0, t=0, b=0), + showlegend=True, + legend=dict( + x=0.02, + y=0.98, + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='black', + borderwidth=1 + ) + ) + return fig + +def plot_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None) -> go.Figure: + """ + Create a coordinate plane visualization where: + - X-axis = Longitude values + - Y-axis = Latitude values + - Turbines, lightnings, and storms are plotted as points + """ + turbine_lat = turbine_row['lat'] + turbine_lon = turbine_row['lng'] + + fig = go.Figure() + + # Add distance rings as circles on the coordinate plane (background layer) + for radius, color in zip(config.distance_rings, config.ring_colors): + # Convert radius from meters to degrees (approximate) + radius_deg = radius / 111000 # 1 degree ≈ 111,000 meters + + # Create circle points in lat/lon space + circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) + + fig.add_trace(go.Scatter( + x=circle_lons, # X-axis = Longitude + y=circle_lats, # Y-axis = Latitude + mode='lines', + line=dict(color=color, width=2), + opacity=0.6, + name=f'{radius/1000:.1f}km Distance Ring', + showlegend=True + )) + + # Add turbines (middle layer) + if 'risk_log' in turbine_df.columns: + from src.utils import get_turbine_colors_by_fixed_intervals + turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df["risk_log"].tolist()) + norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) + else: + turbine_colors = ['red'] * len(turbine_df) + + fig.add_trace(go.Scatter( + x=turbine_df['lng'], # X-axis = Longitude + y=turbine_df['lat'], # Y-axis = Latitude + mode='markers+text', + marker=dict( + size=30, + color=turbine_colors, + symbol='triangle-down', + opacity=1, + line=dict(color='black', width=1) + ), + text=turbine_df['name'].tolist(), + textfont=dict(size=18, color='black'), # turbine name label font size + textposition='middle center', + name='Wind Turbines', + showlegend=True + )) + + # Add storms if available (middle layer) + if storm_data is not None and len(storm_data) > 0: + # Convert storm data to DataFrame for easier handling + storm_df = pd.DataFrame(storm_data) + if 'lat' in storm_df.columns and 'lng' in storm_df.columns: + fig.add_trace(go.Scatter( + x=storm_df['lng'], # X-axis = Longitude + y=storm_df['lat'], # Y-axis = Latitude + mode='markers+text', + marker=dict( + size=20, + color='purple', + symbol='star', + opacity=0.8, + line=dict(color='black', width=1) + ), + text=['S'] * len(storm_df), + textfont=dict(size=18, color='white'), # storm "S" label font size + textposition='middle center', + name='Storm Cells', + showlegend=True + )) + + # Plot lightning strikes (foreground layer - always on top) + lightning_colors = [] + for _, lightning in lightning_df.iterrows(): + d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) + color = 'gray' + for ring, ring_color in zip(config.distance_rings, config.ring_colors): + if d <= ring: + color = ring_color + break + lightning_colors.append(color) + + lightning_sizes = np.clip(lightning_df['current_abs'] / 1500, 3, 12) + + fig.add_trace(go.Scatter( + x=lightning_df['lng'], # X-axis = Longitude + y=lightning_df['lat'], # Y-axis = Latitude + mode='markers', + marker=dict( + size=lightning_sizes, + color=lightning_colors, + opacity=0.8, + symbol='circle', + sizemin=3 + ), + name='Lightning Strikes', + showlegend=True + )) + + # Calculate axis limits based on data and distance rings + max_radius_deg = max(config.distance_rings) / 111000 + + lat_min = turbine_lat - max_radius_deg * 1.5 + lat_max = turbine_lat + max_radius_deg * 1.5 + lon_min = turbine_lon - max_radius_deg * 1.5 + lon_max = turbine_lon + max_radius_deg * 1.5 + + fig.update_layout( + title=f'Coordinate Plane View - Central Turbine: {turbine_row["name"]}', + xaxis=dict( + title=dict(text='Longitude', font=dict(size=18)), + tickfont=dict(size=16), + range=[lon_min, lon_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False + ), + yaxis=dict( + title=dict(text='Latitude', font=dict(size=18)), + tickfont=dict(size=16), + range=[lat_min, lat_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False + ), + plot_bgcolor='white', + paper_bgcolor='white', + showlegend=True, + legend=dict( + title=dict(text='Legend', font=dict(size=20)), + font=dict(size=17), + orientation='h', + x=0.5, + xanchor='center', + y=-0.15, + yanchor='top', + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='black', + borderwidth=1, + itemsizing='constant', + itemwidth=30, + ), + width=800, + height=900 + ) + + return fig + +def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None) -> go.Figure: + """Create a coordinate plane showing only intercloud lightning strikes.""" + turbine_lat = turbine_row['lat'] + turbine_lon = turbine_row['lng'] + + fig = go.Figure() + + # Add distance rings (background layer) + for radius, color in zip(config.distance_rings, config.ring_colors): + circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) + + fig.add_trace(go.Scatter( + x=circle_lons, # X-axis = Longitude + y=circle_lats, # Y-axis = Latitude + mode='lines', + line=dict(color=color, width=2), + opacity=0.6, + name=f'{radius/1000:.1f}km Distance Ring', + showlegend=True + )) + + # Add turbines (middle layer) + if 'risk_log' in turbine_df.columns: + from src.utils import get_turbine_colors_by_fixed_intervals + turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df["risk_log"].tolist()) + norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) + else: + turbine_colors = ['red'] * len(turbine_df) + + fig.add_trace(go.Scatter( + x=turbine_df['lng'], # X-axis = Longitude + y=turbine_df['lat'], # Y-axis = Latitude + mode='markers+text', + marker=dict( + size=30, + color=turbine_colors, + symbol='triangle-down', + opacity=1, + line=dict(color='black', width=1) + ), + text=turbine_df['name'].tolist(), + textfont=dict(size=24, color='black'), + textposition='middle center', + name='Wind Turbines', + showlegend=True + )) + + # Add storms if available (middle layer) + if storm_data is not None and len(storm_data) > 0: + # Convert storm data to DataFrame for easier handling + storm_df = pd.DataFrame(storm_data) + if 'lat' in storm_df.columns and 'lng' in storm_df.columns: + fig.add_trace(go.Scatter( + x=storm_df['lng'], # X-axis = Longitude + y=storm_df['lat'], # Y-axis = Latitude + mode='markers+text', + marker=dict( + size=20, + color='purple', + symbol='star', + opacity=0.8, + line=dict(color='black', width=1) + ), + text=['S'] * len(storm_df), + textfont=dict(size=24, color='white'), + textposition='middle center', + name='Storm Cells', + showlegend=True + )) + + # Filter for intercloud lightning only (p_type != '0') + ic_mask = (lightning_df['p_type'].astype(str) != '0') + ic_lightning_df = lightning_df[ic_mask] + + if len(ic_lightning_df) > 0: + # Plot intercloud lightnings (foreground layer - always on top) + lightning_colors = [] + if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df): + mask_ic = (lightning_df['p_type'].astype(str) != '0').values + ring_idx = precomputed['ring_idx'][mask_ic] + for ri in ring_idx: + if 0 <= int(ri) < len(config.ring_colors): + lightning_colors.append(config.ring_colors[int(ri)]) + else: + lightning_colors.append('gray') + else: + for _, lightning in ic_lightning_df.iterrows(): + d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) + color = 'gray' + for ring, ring_color in zip(config.distance_rings, config.ring_colors): + if d <= ring: + color = ring_color + break + lightning_colors.append(color) + + lightning_sizes = np.clip(ic_lightning_df['current_abs'] / 1500, 3, 12) + + fig.add_trace(go.Scatter( + x=ic_lightning_df['lng'], # X-axis = Longitude + y=ic_lightning_df['lat'], # Y-axis = Latitude + mode='markers', + marker=dict( + size=lightning_sizes, + color=lightning_colors, + opacity=0.8, + symbol='circle', + sizemin=3 + ), + name='Intercloud Lightning', + showlegend=True + )) + + # Calculate axis limits + max_radius_deg = max(config.distance_rings) / 111000 + + lat_min = turbine_lat - max_radius_deg * 1.5 + lat_max = turbine_lat + max_radius_deg * 1.5 + lon_min = turbine_lon - max_radius_deg * 1.5 + lon_max = turbine_lon + max_radius_deg * 1.5 + + fig.update_layout( + title=f'Intercloud Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', + xaxis=dict( + title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size + tickfont=dict(size=22), # x-axis tick label font size + range=[lon_min, lon_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False + ), + yaxis=dict( + title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size + tickfont=dict(size=22), # y-axis tick label font size + range=[lat_min, lat_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False + ), + plot_bgcolor='white', + paper_bgcolor='white', + showlegend=True, + legend=dict( + title=dict(text='Legend', font=dict(size=24)), # legend title font size + font=dict(size=20), # legend item font size + orientation='h', + x=0.5, + xanchor='center', + y=-0.22, + yanchor='top', + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='black', + borderwidth=1, + itemsizing='constant', + itemwidth=30, + ), + width=950, + height=950, + margin=dict(l=40, r=40, t=80, b=220), + font=dict(size=18), # global chart font size + ) + + return fig + +def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None) -> go.Figure: + """Create a coordinate plane showing only cloud-to-ground lightning strikes.""" + turbine_lat = turbine_row['lat'] + turbine_lon = turbine_row['lng'] + + fig = go.Figure() + + # Add distance rings (background layer) + for radius, color in zip(config.distance_rings, config.ring_colors): + circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) + + fig.add_trace(go.Scatter( + x=circle_lons, # X-axis = Longitude + y=circle_lats, # Y-axis = Latitude + mode='lines', + line=dict(color=color, width=2), + opacity=0.6, + name=f'{radius/1000:.1f}km Distance Ring', + showlegend=True + )) + + # Add turbines (middle layer) + if 'risk_log' in turbine_df.columns: + from src.utils import get_turbine_colors_by_fixed_intervals + turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df["risk_log"].tolist()) + norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) + else: + turbine_colors = ['red'] * len(turbine_df) + + fig.add_trace(go.Scatter( + x=turbine_df['lng'], # X-axis = Longitude + y=turbine_df['lat'], # Y-axis = Latitude + mode='markers+text', + marker=dict( + size=30, + color=turbine_colors, + symbol='triangle-down', + opacity=1, + line=dict(color='black', width=1) + ), + text=turbine_df['name'].tolist(), + textfont=dict(size=18, color='black'), # turbine name label font size + textposition='middle center', + name='Wind Turbines', + showlegend=True + )) + + # Add storms if available (middle layer) + if storm_data is not None and len(storm_data) > 0: + # Convert storm data to DataFrame for easier handling + storm_df = pd.DataFrame(storm_data) + if 'lat' in storm_df.columns and 'lng' in storm_df.columns: + fig.add_trace(go.Scatter( + x=storm_df['lng'], # X-axis = Longitude + y=storm_df['lat'], # Y-axis = Latitude + mode='markers+text', + marker=dict( + size=20, + color='purple', + symbol='star', + opacity=0.8, + line=dict(color='black', width=1) + ), + text=['S'] * len(storm_df), + textfont=dict(size=18, color='white'), # storm "S" label font size + textposition='middle center', + name='Storm Cells', + showlegend=True + )) + + # Filter for cloud-to-ground lightning only (p_type == '0') + cg_mask = (lightning_df['p_type'].astype(str) == '0') + cg_lightning_df = lightning_df[cg_mask] + + if len(cg_lightning_df) > 0: + # Plot cloud-to-ground lightnings (foreground layer - always on top) + lightning_colors = [] + if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df): + mask_cg = (lightning_df['p_type'].astype(str) == '0').values + ring_idx = precomputed['ring_idx'][mask_cg] + for ri in ring_idx: + if 0 <= int(ri) < len(config.ring_colors): + lightning_colors.append(config.ring_colors[int(ri)]) + else: + lightning_colors.append('gray') + else: + for _, lightning in cg_lightning_df.iterrows(): + d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) + color = 'gray' + for ring, ring_color in zip(config.distance_rings, config.ring_colors): + if d <= ring: + color = ring_color + break + lightning_colors.append(color) + + lightning_sizes = np.clip(cg_lightning_df['current_abs'] / 1500, 3, 12) + + fig.add_trace(go.Scatter( + x=cg_lightning_df['lng'], # X-axis = Longitude + y=cg_lightning_df['lat'], # Y-axis = Latitude + mode='markers', + marker=dict( + size=lightning_sizes, + color=lightning_colors, + opacity=0.8, + symbol='circle', + sizemin=3 + ), + name='Cloud-to-Ground Lightning', + showlegend=True + )) + + # Calculate axis limits + max_radius_deg = max(config.distance_rings) / 111000 + + lat_min = turbine_lat - max_radius_deg * 1.5 + lat_max = turbine_lat + max_radius_deg * 1.5 + lon_min = turbine_lon - max_radius_deg * 1.5 + lon_max = turbine_lon + max_radius_deg * 1.5 + + fig.update_layout( + title=f'Cloud-to-Ground Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', + xaxis=dict( + title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size + tickfont=dict(size=22), # x-axis tick label font size + range=[lon_min, lon_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False + ), + yaxis=dict( + title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size + tickfont=dict(size=22), # y-axis tick label font size + range=[lat_min, lat_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False + ), + plot_bgcolor='white', + paper_bgcolor='white', + showlegend=True, + legend=dict( + title=dict(text='Legend', font=dict(size=24)), # legend title font size + font=dict(size=20), # legend item font size + orientation='h', + x=0.5, + xanchor='center', + y=-0.22, + yanchor='top', + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='black', + borderwidth=1, + itemsizing='constant', + itemwidth=30, + ), + width=950, + height=950, + margin=dict(l=40, r=40, t=80, b=220), + font=dict(size=18), # global chart font size + ) + + return fig + +def save_map_image(fig, filename): + fig.write_image(filename, format='png', scale=2, engine='kaleido') + +def create_risk_score_chart() -> go.Figure: + """ + Create a risk score chart showing the relationship between distance, current magnitude, and risk scores. + This helps readers understand how risk scores are calculated and what the min/max values could be. + """ + import numpy as np + from src.config import config + + # Get risk parameters + P_0 = config.risk_params['P_0'] + alpha = config.risk_params['alpha'] + current_weight = config.risk_params['current_weight'] + + # Create distance and current ranges + distances_km = np.linspace(0.1, 5.0, 50) # 0.1 to 5 km + currents_amp = np.linspace(1000, 50000, 50) # 1kA to 50kA + + # Create meshgrid for 3D plotting + D, C = np.meshgrid(distances_km, currents_amp) + + # Calculate risk scores using the same formula as in risk calculation + current_factor = 1 + current_weight * C / 10000 + distance_factor = np.exp(-alpha * D) + risk_scores = P_0 * current_factor * distance_factor + + # Create the 3D surface plot + fig = go.Figure(data=[go.Surface( + x=D, + y=C, + z=risk_scores, + colorscale='Viridis', + colorbar=dict( + title="Risk Score", + tickformat=".3f" + ), + hovertemplate=( + "Risk Score: %{z:.3f}
" + + "Distance: %{x:.2f} km
" + + "Current: %{y:.0f} A
" + + "" + ) + )]) + + # Update layout + fig.update_layout( + title={ + 'text': 'Risk Score Calculation Chart
Shows how distance and current magnitude affect risk scores', + 'x': 0.5, + 'xanchor': 'center', + 'font': {'size': 16} + }, + scene=dict( + xaxis_title='Distance (km)', + yaxis_title='Current Magnitude (A)', + zaxis_title='Risk Score', + xaxis=dict( + range=[0, 5], + tickmode='linear', + tick0=0, + dtick=1, + gridcolor='lightgray', + zerolinecolor='lightgray' + ), + yaxis=dict( + range=[0, 50000], + tickmode='linear', + tick0=0, + dtick=10000, + gridcolor='lightgray', + zerolinecolor='lightgray' + ), + zaxis=dict( + gridcolor='lightgray', + zerolinecolor='lightgray' + ), + camera=dict( + eye=dict(x=1.5, y=1.5, z=1.2) + ) + ), + width=900, + height=700, + margin=dict(l=0, r=0, t=80, b=0) + ) + + return fig + +def create_risk_score_heatmap() -> go.Figure: + """ + Create a 2D heatmap showing risk scores for different distance and current combinations. + This provides a clearer view of the risk calculation relationship. + """ + import numpy as np + from src.config import config + + # Get risk parameters + P_0 = config.risk_params['P_0'] + alpha = config.risk_params['alpha'] + current_weight = config.risk_params['current_weight'] + + # Create distance and current ranges + # Use distance rings from config, convert from meters to kilometers + max_distance_km = max(config.distance_rings) / 1000 + distances_km = np.linspace(0.1, max_distance_km, 50) + currents_amp = np.linspace(1000, 300000, 50) # Extended to 300k amps + + # Create meshgrid (swapped: current on x-axis, distance on y-axis) + C, D = np.meshgrid(currents_amp, distances_km) + + # Calculate risk scores + current_factor = 1 + current_weight * C / 10000 + distance_factor = np.exp(-alpha * D) + risk_scores = P_0 * current_factor * distance_factor + + # Create custom colorscale using the new color palette (normalized to 0-1) + custom_colorscale = [ + [0.0, '#577590'], # Blue - Very Low Risk + [0.067, '#43AA8B'], # Teal - Low Risk (0.1/1.5) + [0.133, '#90BE6D'], # Green - Med-Low Risk (0.2/1.5) + [0.267, '#F9C74F'], # Yellow - Medium Risk (0.4/1.5) + [0.4, '#F8961E'], # Orange - Med-High Risk (0.6/1.5) + [0.533, '#F3722C'], # Dark Orange - High Risk (0.8/1.5) + [0.667, '#F94144'], # Red - Very High Risk (1.0/1.5) + [0.8, '#D32F2F'], # Darker Red - Critical Risk (1.2/1.5) + [0.933, '#B71C1C'], # Deepest Red - Maximum Risk (1.4/1.5) + [1.0, '#8B0000'] # Dark Red - Maximum Risk (1.5/1.5) + ] + + # Normalize risk scores to 0-1 range for colorscale (matching our fixed intervals) + normalized_risk = np.clip(risk_scores, 0, 1.5) / 1.5 + + # Create heatmap (swapped axes: current on x-axis, distance on y-axis) + fig = go.Figure(data=go.Heatmap( + z=normalized_risk, + x=currents_amp, + y=distances_km, + colorscale=custom_colorscale, + colorbar=dict( + title="Risk Level", + tickmode='array', + tickvals=[0.01, 0.13, 0.26, 0.4, 0.53, 0.66, 0.8, 0.93], + ticktext=['Very Low: <0.1', 'Low: 0.1-0.2', 'Medium-Low: 0.2-0.4', 'Medium: 0.4-0.6', 'Medium-High: 0.6-0.8', 'High: 0.8-1.0', 'Very High: 1.0-1.2', 'Critical: >1.2'], + len=1.0, + y=0.5, + yanchor='middle', + thickness=22, + tickfont=dict(size=18), # colorbar tick label font size + xpad=8 + ), + hovertemplate=( + "Risk Score: %{customdata:.3f}
" + + "Current: %{x:.0f} A
" + + "Distance: %{y:.2f} km
" + + "" + ), + customdata=risk_scores + )) + + # Overlay risk level contour curves at fixed risk score levels (over normalized z) + risk_levels = [0.1, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.5] + normalized_levels = [min(level / 1.5, 1.0) for level in risk_levels] + for norm_level in normalized_levels: + fig.add_trace(go.Contour( + z=normalized_risk, + x=currents_amp, + y=distances_km, + contours=dict( + start=norm_level, + end=norm_level, + size=0, + coloring='none', + showlines=True + ), + line=dict(color='rgba(0,0,0,0.5)', width=1), + showscale=False, + hoverinfo='skip', + name='Risk level', + showlegend=False + )) + + # Update layout + fig.update_layout( + title={ + 'text': f'Risk Score Heatmap
Current Magnitude vs Distance (0.1-{max_distance_km:.1f}km) - Higher values (red) = Higher risk', + 'x': 0.5, + 'xanchor': 'center', + 'font': {'size': 24} # title font size + }, + xaxis_title='Lightning Current Magnitude (A)', + yaxis_title='Distance from Turbine (km)', + xaxis=dict( + tickmode='linear', + tick0=0, + dtick=50000, + gridcolor='lightgray', + zerolinecolor='lightgray', + tickfont=dict(size=18), # x-axis tick label font size + title_font=dict(size=20), # x-axis title font size + ), + yaxis=dict( + tickmode='array', + tickvals=[0] + [ring/1000 for ring in config.distance_rings], + ticktext=['0'] + [f'{ring/1000:.1f}' for ring in config.distance_rings], + gridcolor='lightgray', + zerolinecolor='lightgray', + tickfont=dict(size=18), # y-axis tick label font size + title_font=dict(size=20), # y-axis title font size + ), + width=1150, + height=800, + plot_bgcolor='white', + paper_bgcolor='white', + margin=dict(l=40, r=220, t=80, b=40), + font=dict(size=18), # global chart font size + ) + + return fig + +def create_risk_score_examples() -> go.Figure: + """ + Create a chart showing example risk scores for specific distance and current combinations. + This helps readers understand typical risk score ranges. + """ + import numpy as np + from src.config import config + + # Get risk parameters + P_0 = config.risk_params['P_0'] + alpha = config.risk_params['alpha'] + current_weight = config.risk_params['current_weight'] + + # Define example scenarios + scenarios = [ + {"distance": 0.5, "current": 5000, "label": "Close, Low Current"}, + {"distance": 0.5, "current": 25000, "label": "Close, High Current"}, + {"distance": 2.0, "current": 5000, "label": "Medium Distance, Low Current"}, + {"distance": 2.0, "current": 25000, "label": "Medium Distance, High Current"}, + {"distance": 4.0, "current": 5000, "label": "Far, Low Current"}, + {"distance": 4.0, "current": 25000, "label": "Far, High Current"}, + ] + + # Calculate risk scores for each scenario + distances = [s["distance"] for s in scenarios] + currents = [s["current"] for s in scenarios] + labels = [s["label"] for s in scenarios] + + current_factors = 1 + current_weight * np.array(currents) / 10000 + distance_factors = np.exp(-alpha * np.array(distances)) + risk_scores = P_0 * current_factors * distance_factors + + # Create bar chart + fig = go.Figure(data=go.Bar( + x=labels, + y=risk_scores, + text=[f'{score:.3f}' for score in risk_scores], + textposition='auto', + marker_color='lightcoral', + hovertemplate=( + "%{x}
" + + "Risk Score: %{y:.3f}
" + + "" + ) + )) + + # Update layout + fig.update_layout( + title={ + 'text': 'Example Risk Scores for Different Scenarios
Shows how distance and current affect risk calculation', + 'x': 0.5, + 'xanchor': 'center', + 'font': {'size': 16} + }, + xaxis_title='Scenario (Distance, Current)', + yaxis_title='Risk Score', + xaxis=dict( + tickangle=45, + gridcolor='lightgray', + zerolinecolor='lightgray' + ), + yaxis=dict( + gridcolor='lightgray', + zerolinecolor='lightgray' + ), + width=900, + height=600, + plot_bgcolor='white', + paper_bgcolor='white', + showlegend=False + ) + + # Add annotation with formula + fig.add_annotation( + x=0.5, + y=1.02, + xref='paper', + yref='paper', + text=f'Formula: Risk = {P_0} × (1 + {current_weight}×Current/10000) × e^(-{alpha}×Distance)', + showarrow=False, + font=dict(size=12), + bgcolor='lightblue', + bordercolor='black', + borderwidth=1 + ) + + return fig diff --git a/src/visualization/storm_cells.py b/src/visualization/storm_cells.py new file mode 100644 index 0000000..88da488 --- /dev/null +++ b/src/visualization/storm_cells.py @@ -0,0 +1,665 @@ +import plotly.graph_objects as go +import plotly.express as px +import numpy as np +import pandas as pd +from datetime import datetime +import json +from typing import List, Dict, Tuple, Any +import re +from collections import defaultdict +from zoneinfo import ZoneInfo +from src.analysis.geospatial import haversine_distance +from src.config import config +from src.utils import parse_period_string_to_datetime + +def format_datetime_for_display(datetime_str: str) -> str: + """ + Format datetime string from 'YYYY-MM-DD HH:MM:SS' to 'DD-MM-YYYY HH:MM:SS'. + + Args: + datetime_str: Datetime string in format 'YYYY-MM-DD HH:MM:SS' + + Returns: + Formatted datetime string in 'DD-MM-YYYY HH:MM:SS' format + """ + try: + if datetime_str and datetime_str != 'N/A': + dt = datetime.strptime(datetime_str, '%Y-%m-%d %H:%M:%S') + return dt.strftime('%d-%m-%Y %H:%M:%S') + return datetime_str + except: + return datetime_str + +def parse_wkt_linestring(wkt_string: str) -> List[Tuple[float, float]]: + """ + Parse WKT LINESTRING format to extract coordinates. + + Args: + wkt_string: WKT string in format "LINESTRING(lon1 lat1, lon2 lat2, ...)" + + Returns: + List of (longitude, latitude) tuples + """ + try: + # Extract coordinates from LINESTRING format + # Remove "LINESTRING(" and ")" and split by commas + coords_str = wkt_string.replace("LINESTRING(", "").replace(")", "") + coord_pairs = coords_str.split(",") + + coordinates = [] + for pair in coord_pairs: + lon, lat = pair.strip().split() + coordinates.append((float(lon), float(lat))) + + return coordinates + except Exception as e: + print(f"Error parsing WKT: {e}") + return [] + +def group_storm_data_by_day(storm_data: List[Dict]) -> Dict[str, List[Dict]]: + """ + Group storm data by day. + + Args: + storm_data: List of storm cell dictionaries + + Returns: + Dictionary with date as key and list of storms as value + """ + daily_storms = defaultdict(list) + + for storm in storm_data: + # Try different time fields that might exist in storm data + time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') + if time_field: + try: + # Parse time field (format: '2025-08-29T15:08:45.002Z' or '2024-06-22 11:13:00') + if 'T' in time_field: + # ISO format with T + storm_date = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%d-%m-%Y') + else: + # Standard format + storm_date = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%d-%m-%Y') + daily_storms[storm_date].append(storm) + except: + continue + + return dict(daily_storms) + +def group_storm_data_by_month(storm_data: List[Dict]) -> Dict[str, List[Dict]]: + """ + Group storm data by month. + + Args: + storm_data: List of storm cell dictionaries + + Returns: + Dictionary with month as key and list of storms as value + """ + monthly_storms = defaultdict(list) + + for storm in storm_data: + # Try different time fields that might exist in storm data + time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') + if time_field: + try: + # Parse time field (format: '2025-08-29T15:08:45.002Z' or '2024-06-22 11:13:00') + if 'T' in time_field: + # ISO format with T + storm_month = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%Y-%m') + else: + # Standard format + storm_month = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%Y-%m') + monthly_storms[storm_month].append(storm) + except: + continue + + return dict(monthly_storms) + +def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None) -> go.Figure: + """ + Create a coordinate plane visualization showing storm cells with turbines and distance rings. + + Args: + storm_data: List of storm cell dictionaries with WKT data + turbine_df: DataFrame containing turbine locations with 'lat' and 'lng' columns + center_lat: Center latitude for map (optional) + center_lon: Center longitude for map (optional) + + Returns: + Plotly figure with storm cells, turbines, and distance rings in coordinate plane view + """ + # Calculate center if not provided + if center_lat is None or center_lon is None: + if turbine_df is not None and not turbine_df.empty: + # Use turbine centroid as center + center_lat = turbine_df['lat'].mean() + center_lon = turbine_df['lng'].mean() + else: + # Calculate center from storm data + all_lats = [] + all_lons = [] + for storm in storm_data: + coords = parse_wkt_linestring(storm.get('cell_polygon_wkt', '')) + if coords: + lons, lats = zip(*coords) + all_lats.extend(lats) + all_lons.extend(lons) + + if all_lats and all_lons: + center_lat = np.mean(all_lats) + center_lon = np.mean(all_lons) + else: + center_lat, center_lon = 36.8, 33.8 # Default to Turkey + + fig = go.Figure() + + # Add distance rings if turbine data is provided + if turbine_df is not None and not turbine_df.empty: + from src.analysis.geospatial import create_circle_points + + # Add distance rings from turbine centroid + for radius, color in zip(config.distance_rings, config.ring_colors): + circle_lats, circle_lons = create_circle_points(center_lat, center_lon, radius) + fig.add_trace(go.Scatter( + x=circle_lons, # X-axis = Longitude + y=circle_lats, # Y-axis = Latitude + mode='lines', + line=dict(color=color, width=2), + opacity=0.6, + name=f'{radius/1000:.0f}km Ring', + showlegend=True + )) + + # Add turbines colored by risk using fixed intervals + if 'risk_log' in turbine_df.columns: + from src.utils import get_turbine_colors_by_fixed_intervals + turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df['risk_log'].tolist()) + else: + turbine_colors = ['red'] * len(turbine_df) + + fig.add_trace(go.Scatter( + x=turbine_df['lng'], # X-axis = Longitude + y=turbine_df['lat'], # Y-axis = Latitude + mode='markers+text', + marker=dict( + size=30, + color=turbine_colors, + symbol='triangle-down', + opacity=0.8, + line=dict(color='black', width=1) + ), + text=turbine_df['name'].tolist(), + textfont=dict(size=12, color='black'), + textposition='middle center', + name='Wind Turbines', + showlegend=True, + hovertemplate=( + "Wind Turbine
" + "Name: %{text}
" + "Lat: %{y:.5f}
" + "Lng: %{x:.5f}
" + "" + ) + )) + + severity_colors = {} + seen_polygon_key_to_count: Dict[Tuple, int] = {} + offset_deg_lon = 0.003 + offset_deg_lat = 0.002 + + for i, storm in enumerate(storm_data): + wkt_string = storm.get('cell_polygon_wkt', '') + if not wkt_string: + continue + + coords = parse_wkt_linestring(wkt_string) + if not coords: + continue + + polygon_key = tuple((round(lon, 6), round(lat, 6)) for lon, lat in coords) + duplicate_index = 0 + if polygon_key in seen_polygon_key_to_count: + seen_polygon_key_to_count[polygon_key] += 1 + duplicate_index = seen_polygon_key_to_count[polygon_key] - 1 + else: + seen_polygon_key_to_count[polygon_key] = 1 + + if duplicate_index > 0: + off_lon = offset_deg_lon * duplicate_index + off_lat = offset_deg_lat * duplicate_index + coords = [(lon + off_lon, lat + off_lat) for lon, lat in coords] + + lons, lats = zip(*coords) + + severity = storm.get('lightning_severity', 'Unknown').lower() + if severity == 'high': + color = 'purple' + elif severity == 'medium': + color = 'orange' + elif severity == 'low': + color = 'green' + else: + color = 'gray' + + # Track severity colors for legend + severity_colors[severity] = color + + # Add storm cell boundary + fig.add_trace(go.Scatter( + x=lons, # X-axis = Longitude + y=lats, # Y-axis = Latitude + mode='lines', + line=dict(color=color, width=3), + opacity=1, + showlegend=False, # Don't show individual storms in legend + hovertemplate=( + f"Storm Cell {i+1}
" + f"Severity: {storm.get('lightning_severity', 'Unknown')}
" + f"Effective: {format_datetime_for_display(storm.get('effective_time', 'N/A'))}
" + f"Expire: {format_datetime_for_display(storm.get('expire_time', 'N/A'))}
" + f"Direction: {storm.get('direction', 'N/A')}°
" + f"Speed: {storm.get('speed', 'N/A')} km/h
" + f"" + ) + )) + + # Add static legend entries for severity levels + for severity, color in severity_colors.items(): + fig.add_trace(go.Scatter( + x=[None], # Invisible trace for legend only + y=[None], + mode='lines', + line=dict(color=color, width=3), + name=f"{severity.title()} Severity", + showlegend=True, + hoverinfo='skip' # No hover for legend entries + )) + + # Calculate axis limits based on data and distance rings + max_radius_deg = max(config.distance_rings) / 111000 + + lat_min = center_lat - max_radius_deg * 1.5 + lat_max = center_lat + max_radius_deg * 1.5 + lon_min = center_lon - max_radius_deg * 1.5 + lon_max = center_lon + max_radius_deg * 1.5 + + fig.update_layout( + font=dict(size=18), + title=dict(text='Storm Cells - Coordinate Plane View', font=dict(size=28)), + xaxis_title='Longitude', + yaxis_title='Latitude', + xaxis=dict( + range=[lon_min, lon_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False, + tickfont=dict(size=22), + title_font=dict(size=28), + ), + yaxis=dict( + range=[lat_min, lat_max], + showgrid=True, + gridwidth=1, + gridcolor='lightgray', + zeroline=False, + tickfont=dict(size=22), + title_font=dict(size=28), + ), + plot_bgcolor='white', + paper_bgcolor='white', + showlegend=True, + legend=dict( + title=dict(text='Legend', font=dict(size=24)), + font=dict(size=20), + orientation='h', + x=0.5, + xanchor='center', + y=-0.18, + yanchor='top', + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='black', + borderwidth=1, + ), + width=800, + height=900, + margin=dict(l=70, r=40, t=50, b=130), + ) + + return fig + +def create_storm_cells_map(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None) -> go.Figure: + """ + Create a map showing storm cells from the fırtına data with turbines and distance rings. + Now uses coordinate plane view instead of mapbox. + + Args: + storm_data: List of storm cell dictionaries with WKT data + turbine_df: DataFrame containing turbine locations with 'lat' and 'lng' columns + center_lat: Center latitude for map (optional) + center_lon: Center longitude for map (optional) + + Returns: + Plotly figure with storm cells, turbines, and distance rings in coordinate plane view + """ + return create_storm_cells_coordinate_plane(storm_data, turbine_df, center_lat, center_lon) + +def create_daily_storm_maps(storm_data: List[Dict], max_maps_per_page: int = 2) -> List[go.Figure]: + """ + Create separate maps for each day with storms. + + Args: + storm_data: List of storm cell dictionaries + max_maps_per_page: Maximum number of maps to show per page + + Returns: + List of Plotly figures, each containing maps for one or more days + """ + daily_storms = group_storm_data_by_day(storm_data) + + if not daily_storms: + return [] + + # Sort days + sorted_days = sorted(daily_storms.keys()) + + figures = [] + current_fig = None + maps_in_current_fig = 0 + + for day in sorted_days: + day_storms = daily_storms[day] + + if current_fig is None or maps_in_current_fig >= max_maps_per_page: + # Create new figure + current_fig = go.Figure() + maps_in_current_fig = 0 + + # Set up subplot layout + if max_maps_per_page == 1: + # Single map layout + current_fig.update_layout( + mapbox=dict( + style='carto-positron', + center=dict(lat=36.8, lon=33.8), + zoom=8 + ), + margin=dict(l=0, r=0, t=0, b=0), + showlegend=True + ) + else: + # Multiple maps layout - will be handled in PDF generation + pass + + # Create map for this day + day_fig = create_storm_cells_map(day_storms) + + # Add day title + day_fig.update_layout( + title=f"Storm Cells - {day}", + title_x=0.5, + title_font_size=16 + ) + + # If this is the first map in the figure, use it as base + if maps_in_current_fig == 0: + current_fig = day_fig + else: + # For multiple maps, we'll handle layout in PDF generation + pass + + maps_in_current_fig += 1 + + # If we've reached the limit, add to figures list + if maps_in_current_fig >= max_maps_per_page: + figures.append(current_fig) + current_fig = None + maps_in_current_fig = 0 + + # Add remaining figure if any + if current_fig is not None: + figures.append(current_fig) + + return figures + +def create_monthly_storm_maps(storm_data: List[Dict]) -> Dict[str, go.Figure]: + """ + Create separate maps for each month with storms. + + Args: + storm_data: List of storm cell dictionaries + + Returns: + Dictionary with month as key and Plotly figure as value + """ + monthly_storms = group_storm_data_by_month(storm_data) + + monthly_figures = {} + + for month, month_storms in monthly_storms.items(): + if month_storms: + fig = create_storm_cells_map(month_storms) + fig.update_layout( + title=f"Storm Cells - {month}", + title_x=0.5, + title_font_size=16 + ) + monthly_figures[month] = fig + + return monthly_figures + +def load_storm_data_from_json(json_file_path: str) -> List[Dict]: + """ + Load storm data from JSON file. + + Args: + json_file_path: Path to the JSON file + + Returns: + List of storm cell dictionaries + """ + try: + with open(json_file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # Handle different JSON structures + if isinstance(data, dict): + # Handle structure with "success" and "data" keys + if "data" in data and isinstance(data["data"], dict): + # Convert dictionary of storm cells to list + storm_list = [] + for storm_id, storm_records in data["data"].items(): + if isinstance(storm_records, list): + storm_list.extend(storm_records) + else: + storm_list.append(storm_records) + return storm_list + # Handle structure where data is directly a list + elif "data" in data and isinstance(data["data"], list): + return data["data"] + # Handle structure where data is directly the list + else: + return data + + # If data is already a list + elif isinstance(data, list): + return data + + return [] + except Exception as e: + print(f"Error loading storm data: {e}") + return [] + +def filter_storm_data_by_date_range(storm_data: List[Dict], start_date: str, end_date: str) -> List[Dict]: + """ + Filter storm data by date range. + start_date/end_date can be 'DD-MM-YYYY', 'DD-MM-YYYY HH:MM', or ISO (e.g. 2026-01-22T07:00:00Z). + Storm timestamps are converted to the farm's local timezone (config.timezone) for comparison. + """ + try: + start_dt = parse_period_string_to_datetime(start_date) + end_dt = parse_period_string_to_datetime(end_date) + if start_dt is None or end_dt is None: + return storm_data + if re.fullmatch(r"\d{2}-\d{2}-\d{4}", str(end_date).strip()): + end_dt = end_dt.replace(hour=23, minute=59, second=59) + tz_name = getattr(config, 'timezone', None) + tz = ZoneInfo(tz_name) if tz_name else None + filtered_data = [] + for storm in storm_data: + time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') + if time_field: + try: + storm_ts = pd.to_datetime(time_field, utc=True) + if tz is not None: + storm_ts = storm_ts.tz_convert(tz).tz_localize(None) + storm_dt = storm_ts.to_pydatetime() + else: + storm_dt = storm_ts.to_pydatetime().replace(tzinfo=None) + if start_dt <= storm_dt <= end_dt: + filtered_data.append(storm) + except Exception: + continue + return filtered_data + except Exception as e: + print(f"Error filtering storm data: {e}") + return storm_data + +def filter_storm_data_by_turbine_proximity(storm_data: List[Dict], turbine_df: pd.DataFrame, max_distance_km: float = None) -> List[Dict]: + """ + Filter storm data to only include storms within the specified distance from turbines. + + Args: + storm_data: List of storm cell dictionaries + turbine_df: DataFrame containing turbine locations with 'lat' and 'lng' columns + max_distance_km: Maximum distance in kilometers. If None, uses the farthest distance ring from config. + + Returns: + Filtered list of storm cell dictionaries + """ + if max_distance_km is None: + # Use the farthest distance ring from config (convert meters to km) + max_distance_km = max(config.distance_rings) / 1000 + + print(f"🌩️ Filtering storm cells within {max_distance_km} km of turbine locations...") + + filtered_storms = [] + + for storm in storm_data: + wkt_string = storm.get('cell_polygon_wkt', '') + if not wkt_string: + continue + + coords = parse_wkt_linestring(wkt_string) + if not coords: + continue + + # Check if any point in the storm cell is within the distance threshold + storm_within_range = False + + for storm_lon, storm_lat in coords: + for _, turbine in turbine_df.iterrows(): + turbine_lat = turbine['lat'] + turbine_lon = turbine['lng'] + + distance_km = haversine_distance(turbine_lat, turbine_lon, storm_lat, storm_lon) / 1000 + + if distance_km <= max_distance_km: + storm_within_range = True + break + + if storm_within_range: + break + + if storm_within_range: + filtered_storms.append(storm) + + print(f"🌩️ Filtered from {len(storm_data)} to {len(filtered_storms)} storm cells within {max_distance_km} km") + return filtered_storms + +def calculate_storm_cell_centroid(wkt_string: str) -> Tuple[float, float]: + """ + Calculate the centroid of a storm cell from WKT coordinates. + + Args: + wkt_string: WKT string representing the storm cell boundary + + Returns: + Tuple of (latitude, longitude) for the centroid + """ + coords = parse_wkt_linestring(wkt_string) + if not coords: + return None + + # Calculate centroid (simple average of all points) + lons, lats = zip(*coords) + centroid_lat = np.mean(lats) + centroid_lon = np.mean(lons) + + return centroid_lat, centroid_lon + +def create_storm_cells_summary(storm_data: List[Dict]) -> Dict[str, Any]: + """ + Create a summary of storm cells data. + + Args: + storm_data: List of storm cell dictionaries + + Returns: + Dictionary with summary statistics + """ + if not storm_data: + return {} + + # Count by severity + severity_counts = {} + total_cells = len(storm_data) + + for storm in storm_data: + severity = storm.get('lightning_severity', 'Unknown') + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + + # Calculate average direction and speed + directions = [storm.get('direction', 0) for storm in storm_data if storm.get('direction') is not None] + speeds = [storm.get('speed', 0) for storm in storm_data if storm.get('speed') is not None] + + avg_direction = np.mean(directions) if directions else 0 + avg_speed = np.mean(speeds) if speeds else 0 + + # Get date range + time_fields = [] + for storm in storm_data: + time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') + if time_field: + time_fields.append(time_field) + + if time_fields: + try: + dates = [] + for time_field in time_fields: + if 'T' in time_field: + # ISO format with T + dates.append(datetime.strptime(time_field[:10], '%Y-%m-%d')) + else: + # Standard format + dates.append(datetime.strptime(time_field[:10], '%Y-%m-%d')) + start_date = min(dates).strftime('%d-%m-%Y') + end_date = max(dates).strftime('%d-%m-%Y') + except: + start_date = end_date = "Unknown" + else: + start_date = end_date = "Unknown" + + # Get daily breakdown + daily_storms = group_storm_data_by_day(storm_data) + daily_summary = {day: len(storms) for day, storms in daily_storms.items()} + + return { + 'total_cells': total_cells, + 'severity_counts': severity_counts, + 'avg_direction': avg_direction, + 'avg_speed': avg_speed, + 'date_range': {'start': start_date, 'end': end_date}, + 'daily_breakdown': daily_summary + } \ No newline at end of file diff --git a/test_data/dagpazari_RES_coordinates.json b/test_data/dagpazari_RES_coordinates.json new file mode 100644 index 0000000..5120858 --- /dev/null +++ b/test_data/dagpazari_RES_coordinates.json @@ -0,0 +1,77 @@ +[ + { + "name": "T1", + "lat": 36.771944, + "lng": 33.405000 + }, + { + "name": "T2", + "lat": 36.770278, + "lng": 33.407500 + }, + { + "name": "T3", + "lat": 36.768611, + "lng": 33.410000 + }, + { + "name": "T4", + "lat": 36.766389, + "lng": 33.412500 + }, + { + "name": "T5", + "lat": 36.764167, + "lng": 33.414722 + }, + { + "name": "T6", + "lat": 36.762778, + "lng": 33.417500 + }, + { + "name": "T7", + "lat": 36.772778, + "lng": 33.411667 + }, + { + "name": "T8", + "lat": 36.770556, + "lng": 33.414167 + }, + { + "name": "T9", + "lat": 36.761667, + "lng": 33.420000 + }, + { + "name": "T10", + "lat": 36.766944, + "lng": 33.419167 + }, + { + "name": "T11", + "lat": 36.765833, + "lng": 33.421667 + }, + { + "name": "T12", + "lat": 36.773889, + "lng": 33.416389 + }, + { + "name": "T13", + "lat": 36.774722, + "lng": 33.420556 + }, + { + "name": "T14", + "lat": 36.773333, + "lng": 33.401111 + }, + { + "name": "T15", + "lat": 36.759722, + "lng": 33.422778 + } +] \ No newline at end of file diff --git a/test_data/deneme.pdf b/test_data/deneme.pdf new file mode 100644 index 0000000..4d5314e --- /dev/null +++ b/test_data/deneme.pdf @@ -0,0 +1,834 @@ +%PDF-1.4 +% ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2+0 42 0 R /F3 4 0 R /F4 37 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/Contents 46 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 45 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +4 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 47 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 45 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 1800 /Length 295020 /Subtype /Image + /Type /XObject /Width 2400 +>> +stream +Gb".!q3)QDiBR_&&hgrhC\h(M'DpR[sh5@m2Z^77)zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz!4ZB@\U&,E^;J'3Zb*j9iq1q'K?'.lT/kM#G$,YS3\F`1esnnj8'3GW^H$T9HnnV*NdP,U=eLk'EXNm;%NR]p<;q`e5W$uaU45mEZuC<6`bi>K*';j(FgL6*X8fkMf`1bI1)r?$@ZX5e>[$n]-GEQ)=U%HApT8iK^07s[;mMY.:]95M%mO:mq9CW(*PZuuZg?*L)56cPQ06&B.ek2:OR4k)/X9IRg`_@D;akpGj_tRaW19;B92fUPl''h;s2S$#h<%#ELPMTN_=0=Ikbl`RL/F8H#q-FH&Fn(fsG8h_./J,YsV1((-8*-d66O^faC@R'b=`4]%VGCfAHT&Qc##UJ*P"^#GJ*@i7s#Z8JUpk>,/HBe',]*fpH_pZ[;`2,qISCT>0BX;\i&u]dt/d[-h`pEUrJ`gUG?Y?*ap.G1p>YA&:G.Ye$OL5L_d*k]bpS5*sRQdZ6?Zp#f`ug:CdDZ;p>HGBa)p3!=oPX`+QN@+f0kjt%H5q>I;a4piZrgNO_FLLaiZ/%]mjfZijPE*sH9UW6/r>o!D?i;OJ:9kM&IncqC+4?\+q(r#a^LL1o`-_.hKr(oSE\9t,ol]1qcE;`7-Ihf/:mJZV>e"\7C79OOXLZ.3i?&gqcrZ=n:)MSF7So-rpW8R?Z"T048le;^HT>^^RLp)5'Ztum;6_-a]:*g2rp!GB44sMb?&U#\3Dfl[;=ReoA;]tZ).K^KsrsVfl[i"l"+\)"1f'Z'R$e((0'`$o]59.)(SM8M>ZYDQPM2ll+D27k0NM6I.7%5qaamTU%WO+.dWDK?]B;U9(HCHYFj(=j3j2iA)GNj[e\=':JIDWdHRtRatDpaRUfo(1n5(g+&_en/6TN4i+=H>0H[rNrC6YTE:>as-#?bYSY*6%ohU)/[uBDc)Sg;1+66C"YiOoP`Dlo"b`C@t/gSKUeNW=O>O[Y:.$("(I)KKB^,FZ+n"U@PQlVoShaU5CC!'qK!b@Ka#^N<3'V&i6HhnD8Omh:U!HNO6J'G8V_`_ZQ+qYVt9$pOU=Wt5-eKplY(0E,HN3X,=eU7(Vh4mbaShAQ0b#G6aGG/=3Z8N-9qHna<'Qk+oeC%SpgeVbt=U'RY-@0NeO*Sr1:;dAlgbeLg[J\=AQj:0rr%5Ime*>C?15I'?f'k(@#hIB'%9-Cft]hLE&p\SttC\*e'^Ac/(nA7%NfTr`XGj5Q>">.,bF^TV?=,p*Oi9j.kJp;j`VKkdUNVOR)CP)=I%SN:h#_j4SJVr)oi%h@em<)AcH#'"!rf]/Saf+EbAo'AScCfBYkn^A7RF:3T.C1I25q_0,YM<6i-#7_jEFo9LdHdhN&c8Y^7RFfc^o'GS^nOiS?LS!>QefGpf>eeZ$.WFA;QLL/e0O)Hj@+aq$9!05pUZ0W1e/ThEKD%(UipRso]rj6G.Q;8"4V/oU2AP1Y30adBG2h#^MRRl&4b%IfZHn!uj=Bf;r'n=]-1!:bZZZ'd!!"^o3\F`qcV8lgo]t26%,96[,jER8%p!JkI'E4rNO["a/FJC%ghKnPYDo0_83Y\,ZLYg42,s-4WnkC;478d=d=Y6^gik/Vr)75\,`a+^&+dVLeg-ADgm;>gkBQqpf<66B.37TL^\M+3mJWF`:7K!BXcPK>/F7NWO^k8cj6tiSGi/sS:YF=-5OMU&IDYn$oj@ZFldkH!>.,cO[X>^ckiV*>Ze?3M`H\rYA2b!HiN'jGIir0cNh?*pVs>Ik:KicZ>%@9;s0%&U"YJ"$]R=h4OL+1^*N^#%'Ck(=*rTE1U.BIJ&l41&DKB;+8k>K#.NSE\'loqUWL2oSi">eGGAc``X`]HK-2otE$1BSZ7(e9T\]`$nVR3^J@3#5VXWeX2R*OPAO+n*hd>Ir\nr6Z-O:sOfptDXfZ^9eLHDU4m*3Mda9kn#2uZDfF&QG.s5#lEQ]8HY3O=+1ujoC-(Cuk)Q`kMNMn=6&DI\ZLC;lXfe($N$_U_]"]M]IPY34(^6Nm)9K+6F.c^fG2Xbp+1gHmD)G4*mr>$,*$Nh+Cc_#^RF\Aa7SUfuDfV.38gF4]8/:F"cgT<",ue@Z)6[.%;.n#N&;FIErVksSY5km/Y3Suj%Wln?d!qY0!/#/mucL3;a,rAt7*C1)Ig3$J](e*(eXX,dIWH"k^'WiJ#R['AdYo_S.*nNRM5,NFK.i@"L5*iiEGV]UVaH(j1U^R56%f_/`H<*3G`L.c^%[q%pnY9(4KJ9QkYR+l<=5qJ0n8Z*Qk.KKJSr.^'k>&bknRl(C*qmE7]pKf)LRgcNY8TBX#Y-4M;TU$'\4#`W$1k2.8eVro>%e5[JPc"Dp$,cQqDYA^X)$FXp#BQ`!!'=9p<%Y*Mda//&C:7SjO*N*4fFF:XuP*P@68*II-V<1?qp!@YA\7"!&]hWsnb>[DmKQGme;OfK@Nui\Kpd7[*DV3g\*GEVsIdndZu""NB8_P]bQiL^`!\G$Z_'&"3hPPe@@N7C6M9ih1]N8\=T.Bol65Ti.(Ff5S"5"2q?rY!jJg%OQ[\slZ\G)7oPD@8X+hS\T)UpGpj2kf`9JL^;tV2:S1_=dNHs^NnWPiJg%b"o]S0s96j;27N_Ii0fQ+rRK;D8O,m&MXmT.1I.>I6++L_qaJ9_\+6"N>.N(Xl%TkVcLD.GJ=%3I[N_D1X8*Z5(&-)\Y7GPNGe^o'g4'=/IUHY1Pl1iA[D\]T#c8;S[qW4F2N>3o4b4muB\eO^n:Rl3,eiPG*#E-f<5N[TDL/?Cbl*"3q\U)K\cF`AfRjTf2R]pg_Wen;n!Y8_>&>;`@KOTR8f;<4)D#6h4(+pIf&:_aZG@=Tk94IB>LG?@$*#tB0Ou#qGWr7Ki3ViVg>W`.86>S+MMc'eC^2Z=5cr6G^7Gis-QZ'H,((s&[KnE9uZ[e'Z%$PCDAdHn]43<0XE7+fofDG96ojDt,WLs)Xfj.e-=c!EWPJPFpH5O?\OZD%2)/h-qO3K$qY57Cb9>-R:]R*ta_$kN@%`JSSPG'E+P*sTcREuet'_JZ4.UN@G]+6"N>?AFD1ldNkFs3Rej*t/N<[,,5.WW$sUHOa*#c;;gl1Ff:`+7++UPH)@@-;QgmB;g+Eq6:U2j)Mp[$O=maZ2Wj=[jpO@hUR8WPn1lM6J0#XDg9JNY5*BZI[Uc_3K!%XI'D42ptmG7XI5<#&-)\II$ZHmNR)U%SGd#@e;'7R30e`7e:dVa-[XR.`94f%bZS7,jl.F\r;NZL/6MUKg:&'I,.I#)nEO6Vn9iV;RiRiu`$^h>>5*\GiZZ$[0>>*pVn`%3*6t-=IkeZK-KDdIH,O!B]Cm6t19![T'BaR5'lMLO-5W`b\"WQKAZRb_f*k'ZE7tnkHu7;jkmRbGMG\c^$)YC?B7VOoolTG5BnaH/.-1?U)!"R*nobu#N)&W0V7T1/_e]r0:q8SDs:^QYJ)_6ddf(6)[3KnnRiS>dp>E,#="2=Y2TRNWc3f>d*9hu!g>g+1a%*`r6YXh3&cP'>Nthd4%F=n2*P]t6N(g#JbC0Al"@%Fq'JWo+ZHVgP_PlVFg5IH6fGKK[V;P@rnk/?E"i\!FtY/I[=ARn:2+KncjA$]8jgg:dN*3g$YfG3hC(Qf7/u;fNUhp!Y77Pp[X^ZPMFA*oC@jd?3:)rUeV)iW%;X:X*,Al+R7,Kr#ju:iX$@opb(J]&&$O?CI*R+Z,cJa,.uB*U%.>T/.:2P(*;lLp:Ai(k3J_](?VtYj06s%#pksfRE%eLJ3GkYGg)MeaWol^H@s6'aMROB\Ba;/^3[UI_EguMQ@?=7ms%)iPT.Q:5=JeE6JuUMrJ[8A^RE84o_3WW0sm:8%^r1m.r5=(E7O^nKXg0mp`m6o"hi`%Q!n&pc1oZSh.D:eg".%3-n9TC7!_C)1T8=D<62FIEDfd+$1e.8"G;[2h#Xi=Ij&Rn6O_IY:)s6=$@I>?[j'p7Um*geOsMlqZ$KZdpIEB<`iI"N#\IK>i[;B+S*?]7o0,+I;;rQ]5DeI)@lcdJ(n<`Um;Mm!!%X)^=al_W(R4J3pP`1DcXV0UutLG&GO+Dgk\f],Wah&.IH1,s/:.XD1"P([lM3-bGM^i;AkN"4`KKJFL2;QoO@J&38NE"_J+$O:3H;CIW6`Y`NuZb;fUe:VSB-BgPoBt/M/rOrL4=X*&G#!=buSpnCp0gWVCZ7Zpes^:X,dp`a)/1Oq5PCq>A(T*j=cd:1^NuE?Edc*Z7u*LE(ud*S5-sc,BA%AA%i:Vsjc1T+[\)#^WPb2mjV't(&c?TkC-BYK3hf_8[CU^pq$W6D`r=%JbtcMhPrS2*#;KYjVdAsng:R,2.Z$5rA@f`3'N_EFA;[slGk#H1uRtkX@^0aY8J`W>h[S*06b^Fi;+!,&VZj,]"YTKkOIE)3rd@/Z1mnPd&Z>)C0tm?Tq+kY]S[0,k?c78_ZAr'I>;UH0GSf:X]Vp?\ut%+XSOrg3V1&kNh+$AE4nt!p%F@5*p1,;D1D3arKRmG>-T]NaK'70?>tblHci@MV"M8\Bek:e.2]pbpPZe:E8@*P$?-.8s,MY$B54\_X8lLfih@6POZiUj10LJ2<]oC4nalIC:[=&,8J)h6C'51mXPlH,S-2DFZG(;b1)DUs9!?aJjHrO&mHBsa51riU\EkEH--IiIZ/If=1Q(O,`-_s4^Ng[:M/kin-[1hT#)fZMmJGlM!=.HHcN)\c&Nn%3HE?npo?]Z\"5Z19Us^9mjLQ0R=8AHIe#>dTksD1cld`pApRNRc^iAQ2!TC5e:`)Eo[0GuHbh0Q0q.60^Na9gri-eX;s/Rneka7@<;WKLq@F_\e]VF]5Om^4N>:>/O:PqFHM&bj\r%F8mt#j?X:,u-6`G;n0k#\o>,bI#%!7[^f-f12b#t:!!!GA9s&cjZ^[<1'%V@2oTP'%XZ'57esGR<\R/sT+7m>Dc&WV!-_SJ-8dD-LEq8R.omYbNq-[Bu5PWpl2=IJ9e6D*_U->`=5)T.":DFmr%HN-N/>/Tiq-X:U;jX_rSRhumJ*9eped4iB"YT.*UUqQ]/nL6/@_;NfKsZAK8m"''s:iWd=1:]BM0/F>kcO6j^FpBD^h^SIE`ECK^#:Z[u:MLhCDT:!POA;FFX8M.q)Wf_mK4i?62'&M36gJhBDR];m049,C_o0Vu"Ng_k2Zg'mFj36G4]))bd8U:N?,Ubr:brXIl2dPL&RNR@$O/Lp.t^5>R>uidG^7W4)(RR/[!4R1&7=BnuW@1MYfFSY<5W;n\'XRSR+qp9d#^K'Pdq,GNY"^WX\JmUkj#-m.g)o*iO"h7<"$Re:#==S>lWXEsQAH1@Zc&)P"c*:!?-YJEiVeg%mjp%'j)8r`H'K+/e^KfquH*[-iP0RZ)Tk`^8gqY7D>LTDg\CtY(nmSNHGg71%X;tqbME:UgfXG?%RfB_56gF)PX!!$ErmFq;?+*dH_8G],g7t#c/=S<&>HEh?V8l@>gMI5_5n^l6^=(EWI'BEm<'i1+:R1H1nlIh_TnH7A%T'a&J[,u>q_KV'XMa+Ch+`VlEDY6hG,+lW6Wo.\':-SI!.1=QQ`4(T*?ItWp^A[K/&]L1m+&&$`UEdcj9VL<(hik7).t2eN7IOCZk(E=B:euT%KCpSjiMqn%kBkpDeGFO-V-ah<<"'4HI.>N=3kDX4Cp]g\na5DTm(9F[oqruRadEYTha37aK,H1Pip?K!1DbEY^V$Z1cr`IKG>A/:l.>51nQRdGq?kr&DiC\;6>KNiD"+#9q-[_*PI$lAI;%]F*d_U_lR`]5kcaghZt\WT/Z%T`*-iIunCrn)Lg-7V6&;k.j59:CdlmSlci:$n8lsJGo?Nll8:S%LX_rqn:LB0[H8U-MMY+A6hHS[bntZVq%sa1kid8E=218?@[f_j**]SO0**f>`Z/-TFX=:l5\`28I?p7]]f]dYQio[Jp102A9[!m9P?p/N+2K`?nVm"$/9%?9t-03AW$Q__F>!-oLh-+`fn&a2%[=.e#F:&,=&(XrM4oLKResgoSqd_h'YM&K_G_5THp0JJM["%qA^ZAUjj-PtTrnsrE,\K;CHj_Nt%q_o54a/s#ZPRUB1mSZl:Vb.6Ctr4gH*&scaTo^Gf"68Z4!/:KIWG-C\m%Jg0:o_MtC*D?@/$4ht+\ZrFfW3e^sAf$Ean"O,25("t(L_]4c?4H'&WgoQ?DL,an:3#!QF/X?]DuU/7e@fa,"-a<34Gju_Hn_/jXZL:1$aQtsK)bnei/88=@6aaP413a+sQEhVN63+'iG.Sb$UjFd!i3V>#I9n\A$@rBu60E$:tdPVn2qTB7b?1#:rNgt8U%BIWk;W1l*f"tGW&3i+WZ$`eNC^s3(Qp+_jf\J^g,KEJP5L4RHB=$]PEMliIl[2LB5->oYPn2GM5X#(.&([4pg`0lf2mVAi+#S4R`eN9a]26s_[4lE3h:'uJ-OMccl!;3AJ,8hsho62^HY`K;poBq\@.>hUGWqM"EmCZLT+;T_@c[;chjQK@reeZFf>YG,NTGdQ_mOa1GeI9TEK>!>Z4Hs!`FO5gWE6utk4K&Pf5B64Z0#\@U_g#&SGg[r6T?Ue_*P$/<.'b]bQXW7T?$CCX*9>>^%F2<\)nb^*e0M_'GU964a178OrQ\+3.*je,]'T&RBnj>HMVqEeZ8I;L*H(R/@[l#>^To=Ae"\CBu$]F39*=&r(^]Ac/(j/,((0VGTM?2+0?oFFqYTrFg-:Ks(tE$(cac#-TsY`\+nFDk_6n&k-&I"2YpMQ][Q?JnDW^MJ7^4>4u?J-(lpTAcCJ>4NN7oD)n)Xh1g,,k@0="/jqkK_@Je2sKG3)2R[&t!+"VXN0kgC@]Hq@Ad1./l3hrW6aThhHJ5X&Pk,e4A-Mo*/g$3_0a-WI^I$I^cLR*``U8simcVH>`U@e'9J"N+uf@/.D0d"V1oQ%[5DqmG3]pS7tr=8>RMY,^^p;=VX&YN4Eo>.$8ZWV7ppCurPO(3m=a%t:`H*cUb4ji'sfq"*?l[1qZK/4-7"t1RPe#k]T-^&%t+$Yp:NFf295$7;&,0gELG$DrW_>#qXI`Z?dK)bm:.H8gW@i]aQdr,@b'NS4huVkC_N5+/h,%g9:M9?OkDl1#\7-r_^5K!SU!dJ"@@iEs:d]5+rA?/JiSk=q73A?Ehh+F38]Hs-[pKomT)G_8keR'ckdao>0EjkeQ;4nZu$OV`i(@k(=1/okM1T52k*_k[oiQgWoQI"B`f-%WBbcN;is07qH\Jg;(,Pe`@EruGjErJoU)H4qTt(k\%i.9b#b-#6H1LmoRYp9\krDL@1G`Vj5PZE%!)SPl)AU#m.\-4p\`MmekI%3X9c-$2G5=d"7t')o&V^Z6QYT>M^0@M0o-Ae"_oAgjC]6Gd]VjdhKLBPJ1bA6mpN2pWq-\U0?ZB"c/0/(j>jpJj3E;U0Y?CloY9?&&oE(B4#`:[/",-?CUE`mis]n$kFH"YBal;HnYg@*cLB_2qFhB?6D':nJ)a,)X[=-EFUB2?h:b#WoH<@Z.$g_%i4%h#;.*Gm&t?)T@XHLZLbmhgT%cW55+MZ>LFtuEVLLG7Oqquo"fqN;_T:5KR/Q1j/)Co-$T$Z_>6N[O3[N/`qh]1m*,+&L7e1-?9ORVlIC)'VN2S=(oKc7cX2X#]iq`9MPRmKCrPjW$4U8<+Un1E:0TYJFfL-@Gm>]$4SYfjli!*-Kp?:TMm"Lip%20H=E$/+0)D/K4o@7N]ApVoRWjC5]"U&i6u%m4nG"qTialCm/ReO6mp]1ae&;&i:NUH!(k2"rW)2I;=(0q[Uh+>\2Us)K?tb/O%__B#m#JRXLQDK7l.:]=q\lb.O[_F>2Xs76Z[[#Pi_S#8ip08N3V1)H>NilaKCNHlC)/@"9@t>_j&G2oTnuOZX`jld[Hfd2V(fCX,O'+'^)beWN1SID"t1QE=?cu\_*P$/$I?@A.K+=kN1Z=5Hh#7$)8F<$EaoYo>*O.Hi?6ohMi@aZf[0K_\O&tSB[-+G'Dh9`sKp5IQ0pYfSS)I`I7o[&&o-hIs]1uMJi_P+,mD7$-OaWIb;!i08@4_u#EPI4HRb2VDoKR"Ea5hu9ja-T15GnRZnXWTe=M=.7VqrFRno]<^j0PL.Vka4gGL+5Km$B>3NI+!@n]7XQ78!T+`LM\3cc%EdmJZThkI'b2]C!2_]kcNa[q@6`mjtD$OjBX1(7`X$MnZ*.6F:FF\p4(F\rr0=o[sUfepigX.e-=h*t1dfbZCu8=ESt:lbn,nb-m&\l#:uYjLE80qIY,cFiHIL4-u8>Xs/lBJ\h(-Vg88W2Kt$o\qY[9hM$+eLsRF+B6!oFP*-Jo8VLJa$%aW,?hX$b7c,@VrP_k@)5;oCanVXhuDS([^EN@?ZgSM$KJ&AL.80=rkt?&?r,`5FPu`+ptk0fSb0bSTc2\Z*f?S&QjhO@i3`;1dVm$@/);"KmC_;]W@_Bq@ES+^_KHaip>5UJ&,$EZ]iE^hAQR,G,$VI1o>.$87hP8oD:lg\m5H#=L*).^@J0r,ipa58_MYh1E$,`fjjLRlXk(CLNDp>=2C,e4J$68eS`6V@SL7eJ-DkTq+dt_6+%MpZo@`Rm=EU">NTnMC.f;mFGq-_%l/@amO-:D-F2ld%gMuj^;#BLAHDbkO(?H'=seO)G8"-F,6O"X-RQ1V#ndqn/o0UTIcFoLLN4S`A\rC/FkpX&3V>_h?eYbtTuh;*L[@eC,PifD"7Gke7>/,o1b"Wpe^5(>auE!m\rI'E4rNAs7!8lbCQ4B-Or`guC/Uh%p/=J*VYM]MRDc1f$;;Y^VJmK09XO9Q^ta-.@s)&o"fpa=`Db):P]K%4d"-s("n`O"Z-HLF5QONus$ZrVEPRI9aUlT7!Klii\mIk0+'_0P`PTQgeG9&E;kl`Y[&^K@ZU\q,a]nD-X&lNV4#1a(>:uH4MLLT+CW0gHl,t*t6ls*6b#Qp^ZMVh7W86mOErN*t5:JS:%Gb"6%N*jb!a&VdK#2!NihGe^qC^HREa3f1,c!jf@q+Ai#1G0<"[oi]HUD7Njp=5@V"VF@pcU7b=TDn)gU,A-hj/nKcG8f8&A]?r+Lr4o_3W]ORs%r:\HHdXO2I51jrN@gB#hQcI?GQVPtk(CL4@jg:?PVF^m;<%\rrLOOsV5h6U5@aK3$ZBn7Z7DM5TCTgi@Rh"nGMe]j4bUL9^#u'^eB%rrLX:KN]FgpBlY''m&P-%8]sHj4onm"P4,dH.EEpMkVkC1Hp1rTM(qP!6JLX)[SZCoHLa'$=.Z[+0*c\p"%l95i+UGB+_LNf+^Ng4bR1Td9<6[nj#8oXNR9pXO^%jiXu8dP5g7)Aegd,)\Xun_o2RVq7F;N;$AM;lI5>L?bel=7b3_e&(f6DonI?PNq=-jcOQ79[+aF(^u[qIT!M5.'`g1Z1..ZYqr!h3BjP!To-QHFM-d3HC4^0PEr#$4.A>-[b]%Mi6huDRe,h40LoQ.LK:nHF(LQs)n,CI4U::ITh(q!]XN!"c!.D5Ak4D`Vq7Phn'%QG\$8(4mQ`&?FRc;^,@oobbI'rQlT)+Db(D;\!qE\J$Ic=7*^G7I-5/6sG:VK_FS,oY!C)*<5i@jo9q;cAQpTu$AQR7Bfn+kS5Df4B8mr-0>FNo$[p"Xs[>.qkM8UU4(WR.;LgcHT-C;I4%1(H;_=_l`K?BH>k>jLrBHi@J8_1,3_O>1?L95E[c&,bC?jS9F&S,@Y6S"/Z7,n[U-iR"N8',4K@j@H8IN+T2!Nh$e)J$pZ`M'&EKY#iKEDiI94Etr&2/Xpp:p:9#l7;-X,@iLm&MmXIeSF(KR`f;5UT<<+7rbX'2E\0[]qXkV)S.C?DZ?df'X5\eVHTqYuLQmWRs8l2:;3*<5.p3'!P=psW'80Tl.,+aJ0Rh`?#S7/55P(9Lp;q9H>00D<*SI;o!SB.2i@Q>N>>D0le)]P/c.m)ZGqT@!marUVkE@qeHMlj>rTPN4=`Db)f5^&t`bi=T!>_e>\*q+6K3TR4n?f]at?=?Y'++^lg9QeT?g`IB'%_uT7=+)s0='$B_U;mIjDBQ@G?)-_pIUmfs5N.@]Cm6TIk`LX/_g#Rh\P[u*&tq'VnWYBHE.f>ajXlUChqcIf)HJ#2Ri'6(i$A0DJC4L@2h4HlJ/8f4^k0j]Q.:[YPNRt:s/X]f8VptkHu8&]a]`jiJ9#.7Au3c/n#Z/iRB)OG\G4V>Il^4SS#X3ZX4\8IO_EBTpEQ(n:iO,MEEMshhAK+X05juac9Yrebnp^'/ddU+%.-Jm(LI*3sQ>]mJTomRjX6P%S_eL(CO>Z@=#_s9lZ3UF1!!!oQ^J1T)HGbK!ptjPfa3?6K2uipmdb!)!z1mP,4f/C&)WHYsBbGH:of>etg^G3f@)a`9P*idgLe8a_FqXhWYKJ<_BC]FJ-0DQ^j!!"^Km:eeJ]X$UTOlP;e^3Jn_)n*0Ahlp\D]Z$L,0lh[>>*D.\5?p>#sSXP*OFN$\"pSVI/m@bE%2!2(b9+92BA0Vg#j@'@JKDp9fEUeNG_#IB3SrUAca:7j&KU]:B/,p*6i!!%b\?Lu#%s6h2U=)TS#R*DecI./dB]QNM`KsHUC!'ic1%fu_2!(5JjcKiYSD&PQTp4Ij@nHV:*4MB52(QXLnna_oYo2f2.I'r;r%KUq1O%c,=d1[bFrV2_sdg(d0keX/<*AR8q\*%UGh-5%'!hmmpt>%iE"r:8%:$Nf+EcC[cAcY?ifm+MdqH?Sk%>cmAj+OUhL>Oq.U?Oq.U?Oq.U?dMEA>4LE3q=lb3#M<':SM<':SM<':SM=a4=1_4FY:_rjL:a5]X:a5]X:a5]X:a1>8!6mm#%$X1E#a@kD#a@kD#a@kD#a@ke1dLZZ-3IhA%g"&?%g"&?%g"&?%g"'*(Ce_R[)kR9jF]^SjF]^SjF]^SjF]^SoL/j=%rF'u(AT(L,5E?X,5E?X,5E?X,(7i=bsRs/PXRbAPXmtDPXmtDPXmtDPXo.<5kE>L"(dT*![X!?![X!?![X!?![Z82%F@5EaZs"iK0Y:SK0Y:SK0Y:SK0Y:c70>@WD)+p'q&iKXq&iKXq&iKXq&iKXIF/&>6^nLK76V!A83R-MDNKY,Kr0uLFuOF.TfOhPLZk+PKRas%o18gGJ]8gGL34BAOc0qjoDF6DDmH*ckc%:SfnTLAXN!Led%)(:Dj&1EZg&1EZg&:/K`d\WDk@,g+\K4Pj<#a5c#-.3S=coe\W5[!0#5`+QS5`+QSd&Wp3O(RI*T7?icf``A4jF]^SoL/j=%rF'u(AT(L,5E?X,5EAR8j54RjF]^SMYBR.h7Imug9o+j0Aj!B_s,XJqlsr;pD`oN:_rjL:a5]X:a5]Xo]l#/bFU@J5idP_M2Lpohn47+j,ZF-fH^$8#m'H9nj/p"nj/p:nj/p:nj1?,q=?Y41G7+:T6@ergHk7]aHk7]gHk7]gHk>kYI/3??C""R.JL=3_GkC4IZAUWJaTmA02;AugcJWGD1boVr"Lq7K#D'2%#D!N0#D!N0#9g"jcH`h5f?TpKGLE9YZY%JYkbF3U*k^i+%=.M1TL97`7IT-K?TnbB^Z:#4=lb3#M<'9tC%[MC*RtZ8A`\QrY?u3dDr7p9e+2VD>?Y3RmFik$JbtOV&1CDnIF1-"m#V_ZoIp"_:_rjL:a5]X6f%b?qcBt-4b*J/.9!*ss+Z$4\$q]0..Si6+$=uJoW23%WZTd$A&jVG/-\p3q&iM.LAR^[qW]U,+@,si%g$=)%g"&?%l/6\*U(Y2.r01]+(U2=H1U0Q%3%M-kbA:h(=\:iB$[()e^[ZC1$QY"kCXR(?CX-'EkQEGi@[I,J&hkG&f'Yh5&oRMoc[&EQOr*S+Wgj0+s-q[,TgQQ\od2bl#2"Abr9!Wn\tCo^L,A[Z=_@MZAV:-"u@>0kB[LO/BA;+K1V7R?+bE%&BY*>:-JrJW*j!)ji`uTrqPM1-!\03H2eWj\:=E`!;Ek+>M3?(#D!N0#8qR-SNF:=Bb_WTsYJ0k5m.-0%P./1`J`3o`CAJ^siK:*8.T3\S8GEkQiS=M&`nnt=AZ;l2*L[&E=O+Nn0TK,6.FaI*QVh7@_?aodl'4p[BD4pX!%+jkd'd,]gu:Dsl5RsYbqOaN7&(=pf'P9s;M2m9pg>"1\8C=K3<]6Bl2I@hQX4te,9L1'Gd776ju<)2@*bB3@/BN[eLT2Hp$96$\d#Jd5e5s.5`+QS5a*#trT=8W4p^nY+jn$G#D!N0#AI?`Ie[@FF!0@hqs?+J`mbD%_rpL's*iXB5jq=e#D;?oYID!MDr/-Pq=T:a5]Xcm4?6s7npD&&LJD8@3=+6(gX:6(6B[\T>+K_8P!2@paZWe^:-^NuBl*r&=O8HkJW80k3]+F$T3:ap.rnjF]^sjF]^](_Zf1"&1rbpS8t[^<$V2'$1CM]70_Tk09A[rqPN755JskqXkJ\JbIANn)VRq1R($-oM.RUPXmtDPXmtD1l3C'Im26?K5aU)'()09&1EZg0W26icd&%@1X;Sh9UTqji5)_BFYB4ACK?R:n==d[.=2QgSU:tsOaI]gOaI^R&jrDnI%\RdiLVkOg,JgM:a5^C*nRT&T.shFFTOF?il"cn:6f+rHe-&f$]F(uF6:[qHOf81Hk7]gHk7]gHk9s(6_aU.TZIGtShq_H2u+Rm,5GUf)`DN2(IEQ>_$=Php=((Q'fG3d_]ZAVNIjF]^SjF]^SjF]^](_Zf1"&1rbpS8t[^<$V2'$1CMpuIW7lK(jKB$Dg;FSE)FISQ=gWNY:=`/,.'T0?P(fuZZH#a@kD#a@kD#W.A]d=4@,jF_DI)A;G,Hk7]gHn^W!LC`!pU,HoE=E`rsEUE/\IE`"@n(q>nWQ@ds#a@kD#a@kD#W.A]d=4@,jF_DI)A;G,Hk7]gHn_IJs81^(3Rkp"$bO$_b:_M^Do[kYq8:%p5QAC_o$Rd66(gX:6(gX:6(gdXM>m*t-6InfdZA/"jF]^SjFd7K5(*.=pYJP0W-@-IpPU]=E&p3sg=tA[9UV'7$r3BVH-`'FAXAXqOUhL>Oq.U?Oq.Ug@S$-`&%$f0#J7Td?,+cen(Xn6f-\/B,RfHgT'2M*#@nYam@/L-^RRN+gF<@'LIfSS&lUO@4[>Va1:T5?#^l9`(nDZ4YN)tEF9aRRT:SilG3Ke(_j^2imM]cTEmEnl_*3BYN"?iOTu[2UJgSI7;OXPcgQ"UF7.ho6\c/*Aq0^Om0=.C4EJM^e.(p>tL6rGkh:<5-jXm=7M[&P7MhC]2;klG^05\kpK5tXo@9T*dT_lqn4(8gt0=9reI*l;ngsg2gcUVZKq,F^O,D(KhE.FH$S33jBFR,%\+]i^<$V<$Pce8=`aK6m45e^d-ThG5`+QS5`+QS5`+QS5a*#trT=:DlYk'">i,%k`:s8's50#3;U\`3pr(6naj6S#2.=c+0=L<$s0([FP#&gd_hU=Wh+5S&_L1k9^O'kR3t"D%4SRR:7n8O:T*P@cVHVs9Vp87*6#6,&foJ8eO(VdfeCVH[TB(VrI>6^GnT1:Lra0ANroBbc.qUoFH_De(:gM38QQabjaTi3bFQl\Nk?r,KPUYH@$eaTn5`+QS5`+QS5`+QS5a*#trT=8nfr@a]pN\WX&"Yf5Oo4p0OlL?8qQ57Rdmj$f_?U+?YqI)iW\7PQO'l,^_iTTfqaX<^fC^pDknBSp!2n/4'HcD3r7ZMh:b/+TlG2;NQ:#s?WBtk[IWDjfg6.`e6h-`h2.?-eg!f,f#]nMOrSKP`Xkg3E'Jc*7_WQ!`lUF1hh9UbY5M,SQo(A8h4(B3_Pj6)@@1Gt+/b&cT4=2d->Q`_lH^$1N5mlgBr2b/jUGOD3U-VpB']diKlW6_%3%OGK\F39+0o:s?%h5Q(Rj&e?`oS_,.UP(D*%(Hk7]gHk7]gHk7]gHk9s(6_aU.TQPF$rqcZpEb"q',L?94H&cR.ng&tiI:LOBiHle0MX_iQj=jG4rK;:B/iK.a1jF[Vio6U-A6Gql:5`+QS5`+QS5`+QS5a1E>s8:T)CM3C6*EpABPF)/G#t=F$jQVd;4[-IRX(h0,,VdPtNrPECAj6"KWf>Qf@*c;Z;*$i/LDPF4lQ"Fub*<&DG')gmbBCkIpHHc\AmR1[/3:Y`-X7O09]nb#[4Z['=\9Ipn$fR-fHn+s]pmkV-T7$WFBaM[q_i,kYljm.i7/:5hbj`)JI;@\qQ`$Wn?2/VKW[&Z[LEHH\nTacF5It`]GIQVWmdPldkI$7nlNe(&"`7"Pa3p"YW8j<:QMsm6c3ci+bBcVA>h+U&j[I8&_>ETMOW^mobDuVr^ns.LBS;rM/P0_#fW`MWgHM&/A90*l'BbT1qi,c(r9.M8=rA;u^7h,^*NgkBigJR'bo"k2k>?b=VHuT,b>k_17rCfB\PXmtDPXmtDPXmtD1^Wtc:P!mI1uh.=1]6\TX1qrKC/a\?o!5p"WdOU*BAO2jh:h(-(hjHD4#2ak'h*Zr-Q!GdT!p"u!M<':SM<':SM<':SPE6=K'Z^O."KLH!R#)Pn2J;*'/XtJBE:DlK)FpS\+%dE`p@c/[X,>5!Sj&co^1bKs?;$kG+&!^hqRmt7S:4A,&&'i4Gqu?H7ZMEAF0PL(N/:m4_,-P:HOiiApX.Hb-N*S`@t+!qZfuNgGOG@2T0LF&J,SFYp#]rYlt+9da%i."5'_)2g5L<)m^6cHmQ>o>;sU$4@HuX/.T3g'd_B*OKXplRAS^s71@i9HKq:KoFo1S/aX??,'o/[nq-ZfYG[K_'mQPXM6sEmM2rB"(lW3*n!C0BM6(gX:6(gX:6(gX:6(ggAg4+Qla2ndoD&?Ho_+tR7W=k(N?Som/Sp^NEeR,/6Gt];2RHB,H?Cm>:g30Gr37q.o.N1Ng;9aKmj3j3M)sB'O^&OX7O0&a%o^R@DDd4Qq=VJXLKK14!/:le,rkUoQf#D59>c_2*IM(a.d"mc;KR@EWM)D;DU8f9%:J3]\a/66?j$K0Y:SK0Y:SK0Y:SK0YV;')gtqX[kXC4@&Iu_r%El.Xh-KWA>?R:XXVE*d7!:>]\'\U8#,o0m74n(0lUCo@#u%cPt%45?tTNq./r\7l.1)IieB+;VELjs&Zn-f:X^tc^2\s^3lb5$jR7a:D;'^)n&iJUegeoW.BhRE:j1NhOOkiFY3@hs.#M"LO>K3<+ROZW?bCkF`IR\WLSa56_naK*&?g^EF[%OI*Mg\^JpS\@=Ng,;1-&I+Zlq@PI_-=-q)`HH6,Zc"O#mi1;k<[UV0G+/E[I(039?5JI0naiS8_hi%HKrUq><-$D`mPok.$>*U?2cmL(taQk,K3>%p]F9oIQIa9NScgu"2(!ltH=`N?5Cb],DZgqS>gE,^.sSb%G\&-+[V4pV-.+WggeNfXbQJ4.Ifr7p+uaTn*%r?:.AUu[ik$gZIiSAMZj/V65855ggoQ4L8p-gbV.XncX[^tcf4n-lu#_2Sm+9=ALobAnL0@'oa5(:5?/flo6(d1ob6N#Q+FgPWejP"8V(");!Z#cR+pQc#bo<>3+bX"/?'qM);DcBT2TE8,EBZrKc?m%e;p6-f5%qd&fJ4f?'Fp]o2\/6!fID:*^q)$fT4EQ!KL_>%DZ'6TtbuIH5>1[_b>q_1N!R00&F/DZTYV!-!U=&-+[V4pV-.+WggeM3,^O\XTfL1i8P93i-e.#Pk#b4XeP(KI&Lf>e$]VI#tj_lN++PFRmScIY1\8Rs`Rhq-]hEA\CuhTZqj\GbD8kNWn9ME>W]Jn#Fp=KLVb>"A=g0.m)#]KmbI(/0CD3bgIKX??%85,h@)'T7)!FmLU#&9IjDp#M4'Dp(U/.3I'O=rF'5A##eUY8f%.[jF[FFB.t0cPEU3P%ha#-FhF3Y"lt:9^_--'!8n.Y+96A6Hk69'65JVEDSdIpB_G#kLb/_@+C#`mMLkCp5DpM?ur8mX%DEO"%3Y!f.lkNI'_]j`@_F7I!8^F!h;h-%7\lXgN).IA*:H"LA?WQBBc8cM=fg?K4'VBm(Quc9%J_,s`-$0Dlms-8H*bYl:8ckC2>BI$#QPhf*s;S(O:MBm8&to95U'_nIdV*b2dASnlIpQRWaF/P'!<\K-&a2"ps_uQ7is0iP?NUdA8ff5jjgNj=6fcnX(MJdXfSq0qC:cp.eTGrJupo$^?=S`H%W!$F(Vc7mpFKOd_(n>hU.0o#?N'[YpjrTU#t/Bra^"u6TtbuEOiWj43e(f^:lef=5XAT5lfgKq&fZVKR==23'oe7!"pH5nT/p/XaC]<>r!6kiZNAUDNR[n4RqGP0!19N=kqnr%g#:,>[3dLq4PZm(e$Uo!$!.("9=ID%fu`$a9M`2,%NEWmV^)N6&q2#rHj!;d.bbdkiA8hP5;i>L<,$apADs^i\=^K)i\!nj*96#l,SCE.iTM!$jlHj6,ibHk>@pk(>S8URV\JXciljgu"2(!_D#Br4Is`@!9!GkL)s*nj*96#l,SCE.iTM!'!;/#QPJ\Mt(k46Ttcr1c)Uc1H+j/hpi82q=":/kP`_&q?1%6MbKV-Rc3'ZWK>*t%J_,sPc_'i\X3mC'GU&N#Y0se!WYa3#6B?"jF[HJ&b83fO9f!JT=Z'55lcrkMu+Ia5l`>Y^TY28%ou_3o"GB"O:MBm8&to9J0X--!5K3;5lcuP=cAub)tH8pO!KY\)tH8pM9F&aE[;golK[AW"ca]I"9=ID%fu`$a9M`r,NOIW+;(mr5Bf'IK)cra(ABcKK)cPYil):,nj2hjSG*aXJp\+p$8se#R[T\fb/t&!$g[-EB4eefXa&oZ!5K3;5lfgKq&fZ.KIk-hh$s0&J4GdAr=/QFJAg^FqW-_uSV;^He^_=%^BN\N!8PiEcDID5#]CaXn)*U&n)rN0G6.L^!-!U=&AV5,^Xq;th5-sER@0J"M2?4lEK;2+/h/>KWDne6MnE9g(@j`kO9f!JT=Z'55lcrkMu+Ia5lg-g*$!a"31-5+/H('(+BB]Pdfe^`2Jpb`NV+94rc/B6#l%J_,s8!:ji%J_,s/C`5#D(?TjbLf#L5leV;)]S2#7^B>*GdkucO:MBm8&to94O!ZuH?OJ?1V^#fV55as0Cs$4nj*8;#[*+Y],U<,!08C`q]#)l!*LGZ\E3>i=`^OL`HBS^krSUu5#J?SnKHj=j.Nup,D4pV-.+WgjV,R/lE,9t+Jhq)>o/G?q>,"+U#!"pH5nT/nA4p^)/A9!)d4pW8UNK&q&Z*@O4VE$Lk`Ohtl69Y[jf97RX*8F?^H9L^$\ejp'!8n.Y+FqLWrVCYF@A3;O+SGO"@tVV5X3J]j!C.i>0n/P.a9M`.Ot?$>a9Mb,ASuCgUnkrk,:ss#I+[0,b40]"*C)do^fP)YV7eUa#7hmFDOoN**s;S(O:MD;N:--%m=p6m6ULsZ2Bs:H!/ZfVppn![*sD((ZFIXm*s@ZMnp?lrUEP,.PtGUh=4cs2nD`FOmmc[Inj*8iaPFbjJ5q#G4n$t4*V)Jri!B-$J7gPQ%*?W543IlthnRKQPu6Up!$DlZ(dE4'jF[FR8J_%ZjF[HJ&b83fNp)9q6UQKNZmmRVb@ia'DP5`-*s;S(O:MD;#4ct`BAju0<%>F8)u1TV8;&P6ceT'++FltF`Vg8A+96A6Hk8OML51O?1Ge8$l1!."9=ID%g$t[#HP6oj6>$j>agY"&-*tB(?=TGL3sQJ,K\sEL3sO43'oe7+m@TB+VN1U$'0L6(oe\3Ib'3d>ONg$4['a>CICP=aS#(ABcKK)i\!nj*96#l,SCE.iTM^b\qk=1aV+k2tfHBf>NJ!0@IhN1[]ab6NZ<,c,+<.?1;hFi'(LID;-=l"mbp<8W9i/b5qtsTs7=Li"=g0sV8n=CZR9O;Vhr!iPH:^:kqid8F;<`NGU02,XHK_H\KkD2O85GBmmUXs%ZFIXm*s;S(O:MBm8&to9J0X--!1mV4BZQHd:oRE7Bf>NJ!0@IhN1[^L=7<2g`2@]XQgP;dcXq;7=#E!ZSsd,X])Sc$O_422=/m`I&+8Nph9e@epHJ*nonWLKZ:YAt?qYibEE*Z$,NB\G;l44SIiNjBpgfSokMCjH;s,Co`+M$9F;[e"\e!KiEF)XA=T%n:G@OSBS:?]m]f_2sk9hWf'B7Zf&aeBVUfF3RY/k."qh3klL3D!9HG]#8"hdf3eG[:,L]4!PB-h[t8N@sQan-!]Ba&=ROKu3b*(Wh"00eK"jk2/(jH08PBT8;&P6ceV<_%h=Y7BUTU"PiKum^1V?2F[Up4%i'=^ebAO4FGf`?.)\DY6-(Mmj`E%*#p$c`HD'1YPfV:0k0CI#p$!9ud>jp!Inn<@cqW]+rT]NckrBKc?C4^?Re,,Dfb@1^52%)D5!N'q=7lt.Q\;arGd="omVg/O!IuEUK)i\!nj*96#l,Tna*@RZF^\_;G/M/:Nuo;LeoM)Q)s7c*aXc@F8WSeoW>67NBT:8a0YdOr3#o;A7RaEF3uW.ln#A(Q@N!t+^mcl#qXi-;B>.WZbQf_<+u?fkn7dO7Hr/?L`1$"FZ"(ICdKdrL?h+!dCK?L.IDN1dD"QI_1jKrq*F+=lo1IStFdj9L>-Qe@BgPDMG$P&)>?3d30,F:kWHHrNLXX/KlFSU!:\.l4C*:4+s85ZdD)=8K8:H[p!k:!"("43.Y0+\b+?3h3n8Be\U3dpIZj'q9i-Lf'70l@i+>Y-c3I"B>?E^/$jSA9W$jJHKl%_HR1rCEC[H@.>L728I6'3>f(T]7D10N$jFd&^bH,Q^;Oe9Gfm.p/oV#1eR>.$1rPpRs,s!&2J$?G_Bm+qs=L58@@!O/)Vplp3#7mE9gcI2^/'/960&NT"bqFNKq_TV"N:e?>F'3ommjlgERLbRaTPrgkrmt1OIp$oaHiRhCGp.N?`7XBOo"M?/@'-7FkC^T0P$!L$GFrH8_OX8N`Gr4Q0HCo(+S%eu!6:03!WYa3#6B?"jF[FRApo(j4$22bE%ijqD/!]ulSH*cnj*8;#ipUR5s?,(nW3r*2e+Y73KW%9l2H7#@`;OZarBSGqQN)?-m,Ol=5"#e/XoV"35;3ph;/ZjXIc)_m-LX*-lP1U^18V^6-Em:q;*L5m2I%?4(f?ZBT:DMU,=#7#S\VE'KLP`mH9e>SB`VA,dId6[TStLg;NhDpdW]q`:V-\VPAJ\^\g+8okrR.YWB]g4t(kYfZrjRPe@0o[-%3jlSI-MG9S-)O:MBm3/2el!'!;/#QPhf*sD'q#(rV[\N)8pc<`7C)]PhS.+f_a*$HC,!/Ws)B\CT7;mK"u>%\S`fu"FNX#2T1ii4=1q4OHZ`EmFr`Q#Y`D(\FPgpi\:2d+[H[.CXgVmpX196Mp0Us"GZrQp>MM"AH9oKml(;s0oTl9Xr](o]+Y6.21$StX)]U=5ej?hn(+o[$aLY%D>8&DN3,K89E.g:O)hZiXDe2Q9-Me>uCQ>M>.(Ags+<)Sb$HuO(NeS5/V"4#t[q@1?6c)9L)8o[$S1-TOiLOJ96m=&faeVm&>q4OHZ`R7"kE@)d7D"\]kC&1=8P;Oe9`&fd9>3rpT.rD;8#A:8#bQmO[7GV!.fWk5`6Y5H8QEZF$36OcTa<2Q-!,rAB+96A6Hk69;69Y\5WQ\u?28l$SbYT@oN1S%eIML-$AQ&ErPk+Jr89jp?hr^^U&;A^\g*ol$5;KTI\*Q:0[%.+kg8U12(1r)&AXKZDo-\i70e[4KBFT`J87[+!a!WPn.2X9XFOLYTP#%ON%/-q&fZVKR==23'oe7!$!.("9=ID%g%8LaC>)&IE6gr,45aH;a1*Lh!FsIVj[Db>c4n<`LBt]_]I#LbQgq7O((,Ln@q/I#sK0r=/QF!-!U=&-+[V4pV-.+WggeNfXbQJ4.Ifr7k!T#34AeEAU6Iq!FoCeX\gn2Jb/PeBT[KD>O#m+J"_;C03Yjf:4CnBV^-*s7;G[&1fWBB4](/FIGt#6B?"jF[HJ&b83fi!B-$!-!U=&-*tB(?=TGYdh>lkit_[GFTk;[Kpjo0LeJi[J\Uo4r[INI)PMVRiIleu7)N_EG<5$a`+Ui]X?+Uh5t*Oq1_Sf+')q4>V)$@_07"JG`5@gICotF8i,*DoG"lL;uERRm&uWrnsjQ*uVUB[KM1p;CBN:c1$qZBCfQJ^:(nj1\Om).s!E1NMef"7:[&o;8enmNabb7!:N_NeDh%pELaImZC*!$!.("9=ID%fu`$a9M`r,NOIW+;(mr5Bhmdr3$Ej'Utqn:L;V=AlKKKmVOt#DnAJ*mjaN-a0W;2e_cgX+"e$\6lo0(FSQVJ"bgV-0s7'@rXHAet#d+**]Kg.],7R"r4/L9mme=k1neSs@fBH26cP)u1U:-b0S)J^#iom!V7KFribcRFN](4(r5=FXkg[h>2lq=H.K13%Jsok3A`&[?;UJ>Fj_FA/]fT??b9WFqQhPmjnERijR7*KaYJ0[SUNMs,lH+Q_)^NZ@*,c@mYJbS+S4h3"*kJSaT@$cT7TEcEWRGk#IV6N+\FJO6!.>/j]E4P9-BdkaJFYl+5lSk0Snunrj^.W*D<&48Ve!"pH5nT/p'^#Ag5$43csAo'QY69L+>Mr0!$DlZ(dE4'jF\RFb0KuL^H:Y+k#QSE:2WXO?>2c;68j2ij1t-emfr>1Mu+Ia5lfgKq&fZVKR==23'oe7!3USAn=dOC0I\GbbJTGQ!e<\[@f5$;O:Qp_d*s;S#ON%"*]-/7!n49jp^EF[%!03-O!WYa3#6B?"jF[Hj,'"4)J,d5:)2sQ7mHEbQnlXt*!$DHN(dE4'jF[FR8J_%ZjF[HJ&b83fi!B-$!-!U=&-,tE['We2;j3N5QC=8B:ad(p(7[Juq&fZ.KIk-hh$s0&J4GdAr=/QF!-!U=&-+[V4pV-.+WggeNfXbQJ4.Ifr7p+i%g$u$=kqnr%fu`$a9M`r,NOIW^_--'!8n.Y+94rc/B6#l%J_,s8!:ji%J_+HE.iTM!'!;/#QPhf*s;S(O:MDC7*&j8gte&&!dGtngu"2(!WYa3#6B?"jF[G=`FL_QQ-+uAMSd6Ig=f_N#N4>XjF[HJ&b83fO9f!JT=Z'55lcrkMu+Ia5lfgKq&fZVKR=?HH`X(Q?#9==!)C'j7K232Ppc@kZ']277TZWqn%G0X*nQG.Yb#akN8`e=sd),X2q6cXb]*EA1Hk=(dW7>XPi?48EOMInsa9M`r,NOIW+;(mr5Bf'IK)cra(ABcKK)i]L\)i7C)lmIA@k*JahObM?&,@>uQRFF;Fc[,K1`A!K69YYT*$HC,!/ZfVppn![*sD((ZFIXm*s;S(A==$lhkQP19>_.T0;`&T%TR-8AB2*s?#R6%[c6ZDK&bW"0bbuCH*bRr7c$,qp-%Yh;DD.u2VBuJ5<,j9a-ri2!r3shQY:5QFc[+P%"'Ck,9I$gNfXbQJ4.Ifr7p+i%g$u$=kqnr%fu`$;U,Nh0?Xah$UaMQD3JfO6PWDH:[Y^J^KQ+EYWVDgr_^.:_k[i05Z0/eV]p][q_2L6E4U:OD!&$&g3u]+XCGQq]/1fl4.cc3^^_--'!0@IhN1[_O:8aSPRpT@PKmsl,9P46Xh,0(fh0R;_>Z-;G-=)*Q!,tUIpG_/c!5K3@I*hfF4MO'E0K7?>fig:9p:1!>9P[=(?.]\L5I`-F%&s`Id;U=:)?7),C%ZFfqc*I6ilJEsc]aDPK@+M*?CgMRm;!VY.NU!eeFi$3lb1KLQVWs=5*CE6*U2P)ifh4/_%DY=amI3\ICY5DKhA*Y>8g`!=Q/'RYW^&4!PA[5b:X80;c?UTr`'B@4G/@W@*XfV0/>pc`S\5XPR?1]Ze[2orDXYA>k9E2T&4isG]
43Vmq]#)l!9!(sjF\ecBm+@ejkS&AM<9tPP"2']o:piA!-dKo^'258/Lq]ZQC'@#Bf>O%PFB<$fT3kRT]03T,p$d[dO+@B=kAB,eo)*Gthp1I$?;)rV=Fa_/g"8',/H"N,HUF;YQ,dK7,>'*DZW$9a+eDDY"B@gXdDi;=m=*a47j/o*n8^D^T.>AS.e",Mlj(ejXo#\o:`p24)(jY.V7r!*lF!#M6IEg4/9>CLPc-hnko(f(sQM/PpBNDBM.6#J1W488iNl3N-l0:,XN:,kO-.V_='bZ-=mlke[7PYm>aedatfNfqA2nulI>W#HH>?t(iiSk7pg@Y%^"F]ToX)>KC(,(I.3&tK-M&8DoQAnhamSj.,ol-ri?@GcdOZXHX>?3R&Wct4BNa+N1(gtInEY3E*GjU%IYO,`jPjHE'b/8ZAB/0637mnCLNEq`K!,ra(/AKS?bceg(pCt^>hSolREK)i\!nj*7p9W-@mHZgkn3]akF.I9Dc#QPJ\Mt(k4ctMUG7&GsY=kZaI#-G(Em\GEZeYmS&c8.8c8G>>k5#Sm?BkC[/XY6bhAB5)`2+3QX[:1UIW?Pk3PG?LB9d,5`0!FTVrgcMgDsM^)]U*+HSm;Or3\qu-Y^oHL@ml2Mp&/T'ob.^1Jdpn-T'iAURV[NG(/b[I)A7"G4lEn9t0/hK_6SMdr.:@=VrXshfNn8n`^5qT%HVqg8IJYNp\'MpW%7ip1"#Eq/6M.b.Q9ZDLhRh'G6@fibhe3!'$F>T.b"39P[84gN(oL`6ZsFYk[Am0X<\o^.NpJ)9`<0WePmJaX<:"51;RM4sKUqddLK>jG29>dA_ikld#L]18'^>!WY^m!sAN#r<9bjF[FT&R1/E-eZ/H^1i7O,'47'S3C[)24EU.OGZ3&N@N*]H/?[>2DYZFl?9CZPJ^6abA*)p9`U!a9P:LP1R9o2^9_%Su$SjB47iS501nDT:Zg(55E$&r+3=3d:=H7au5A\S\(1Q\qno_4t<;=LmJYCe:).:X>.XDL^W;`)FSu'Qii8m/q'BIo`eK'+Tkp8tM]a*(GiEc4^tWK&P9%qR^/+'a?i75'BhDDDlU-<`QQ_g#hH-Li1`muK6/TVar3*6(lI\:Y9<5Fk]TBfZ@dNb8H!f3(Ygu^4o>0?(8ZY;#]?`6,iQe78ijdYcR#[&O&G[FK!&_DC>ktBTVD[LkZ6+a&`ka#-*TVj%-%8_9;+3-rR`:inBDZ>mS6a,S^lp>8QUdB3CI`+]G!$29%c15kGbsiNj6jsT>%eH;5lfj)p$:4U]mas@Z)4BF2)UYYq5:$a-km#oM=/9nR/k#?\P"1o-!>qSZ*CQ$6UNJt)>PBT8-JKLiePchNQt&;.]Ns&r/>@NrZY9DghF@k?-.;X>3H=SK)i^7i8A!ZD!]uiGJu@j*^"[$3]akF.0.-kk]FD9WiC`*"I]`7()G3An&#,%69YYT&qPFl.'%'r';'Cn16&D)8Ud>kO^fqnZ<@?o.^!N,O:NfZ<@f+p>-[TcCn$>\&i]5icqRR6)XTYhcRH$OZK4jDK63O.]c[*T)K@s.OBtIRFm=@=8j+SO%O8Am5"I_5eMEGW;04iUjt$IIT,p&:f%'-RrT1fUd!Y>ehc5#)'UMicCW7^/ZmIT4g;,WNhalJmB(aP@V:qpp4ti]!F1p"J7c5\o#M9,O6ua9NUDK)jgmaKLmCb7fbA78eK\O-Mpj)-@l+Eq!K%_9gWWU6e;sZ[E$6*l.r_4k$I\cc;WTW_tK"/fr8%lJu[6(4daH\o!W_[SIbUZ%XK,4r=tLc_e-&gBn0;p7n@\U`>m"i8X=-eH@o]UI+g%[qo\rmrR?4q)3KsN5t.Q+V1IXc9Km.ClnS(ZoT(OMCJsk^G@nq(fR$n)g/S"\c*'7%+L<0?3:BK@n,rs%TmsMcGb@%-3VrX$mkM4h9?7>=i8$2Yc*(KhIer^$mG#)jjF]jDG4t5\GW-K?4:q'L!0@IhN1[_O0HVS6*=^]nAt`(7Nhm+S*XfV0hS^Ia2a+K4CKNbK/DY`XCnBT%+;;i^:/>&!A!%@Ucdi#DpPoDuZ@H!8I_Q+94rc/B6#lbpjetHR4]l/']\#l4\--m.o'.!:_D%i33IX$1ku3kG).>Ai%KdSe"::5,J.@<4B)(S,"2'dE\*0[+pVbr8R$MG*sm"A,2e-=8*fO8Gds4[%u?M\Xhi7m@/kmINTk`#>(C*m'A"-rRn(5A,W(g+1=5K`I'B(EA=R-5IhF&+pbtk==eE:>fVcTe`SK.GJ^q4#3)[GO2b6H.!gL>X\qZ0V'.HH*]^kGsoFtt-bpF+mX"TZl%Hk69'65JVEmUh^p*]eS#``;^RV'ctc)$4Pc(T]NCHm!CSRg7]Q)O:o^2(&b^)X/@$s8:s=[.Wn,I.P1+o!`I_mh9'A+M`#Z%gl=e^S$?hNCp*+=Tra8_1fiCiL\5^c+\QQe`nD4k(E8`bI$D,I("[)OquL%Cd&$N8bW!:Vmqb0,C0$;Rp=/Bb7q(cVR35>C1lU[N%FC8a9M`2N(uH\eFi$'m3UqfB_G`RGB]MD'_E46bk1+YH=pOiq4RA^):nXrqa_kpn88X#RO.oqE`2_/qoRC"X1j;%gR_S.koit5OUhKn(CGl5aaC>(\sdhQ!PgK7*s;S#ON%"*4#F.j*R!0X+OBc/^sOE2tr?,TS)#_Ib^@VMF'=QU,De)qo"c8j+TX;`hTVlN8crZT:ZmG.B.!TOi33:f:ogXWK)X3\fII4[=IpW,8=,S@O#SHqpHY<*[s!Y.*D`5JptPdnQGU3kM]o9+:2s5"Ijhm'OX1,9KH;*u"IC74.afjF[G?&^2haBoNEI$ZLQX?q=!_MCPH2ZpD$mftltu+-\POW?5\7$iiH3U/OVc.!*hLU%ou\1nSTlXGUDL(!$DlZ(dE5"mmHO2q;&m&9b17k+dGts_K]pjDrmm;+hHZ,ZW>uKML.&S7?16(r,.1,6mRB2N%>^JIf:2:@:?.EWO8M:O6C$:`]fdgYLe4`Q\O>hd(.7[-gZY&P`+&k(sB]TKrr0rp6q@*Q&jrG>kgkj?(Q))ZH2tgG9jLkYj#,@#3YCC5Eo]/O>"%`BVhq&A/6...<"@^\>4os9WR;'?mq&ht0MbQql+3P&NahjT=7%P,>PaB.E86Fi/Mr9uu2D^%OLZPi!hc2R>L:h6_t8'6JV9>VWYIph4.-US:3ug;\HMsl5s>PS&g<2'F*D:roq)_R&SEU9m16F);3T-T<6uY;_W&O=LINUn!5taVM'!-b4nEhKc#Q7BYhinGZ_6LYOLqmJB%f\l4NsWVS5oO1WfA.kXJBaWb?rlUa9TgIa]4E1CjscAmi..moAGJKX@CUi5r1U';j!C3ei/9&C:*TL=b1oX=!&`lQ2J/El3p`gRW"Kds.uO#2p1oV?)bm:.ge'@I9AXA<9C=]!u=I"0jsf&;j:Nh&9gn)m1XA3?ogIj?[aFU@%dSb)p<7+Xh<8CnSX22=YsOEVu.ik=!/]TI1Cq;BYkXnpo_al<;SHk=*"XVo\h)HBONcAdPth\:P"r$9,59t0`ko$pq:\oHPSbsOZC=U25CfZU2ln_$.tDOkED>[RDWp?s"IK>MZ2rn=t+cdhRdp1UlSjfiH_UgJn-/7[7pe;(k4]EdVW![A/eIYt;(6B_N)jMp&/,]s#ra_cG(d>Zt9G;tX/?.95Y!G6D,c3do>t&b6-#C$3]P]ALB!)5MO0mCf8.dgiNIb$ne3^r)7@nKsSMCI`-'?6l)B4QkdQ+#Z]pI^FQ=Aa5ho(AXV6rC,LK31.J#]6,0._mo72:]B4mE0-BA`X':q-MNCPZ%`rmb"jlZOaG0XVA>I-PRbnHE1^Pni-b"9;5PHk69'65JVEmY6*#j@ItW^ZTeGIF$]+:;+5OUk(8pSHd%bG9S8qgitW4AE;n4/=2KUFW*O\43;l22jERo]Cfk&-'[_s6)Ear#a&\@qA#b"3<961JQn(O1HBNf$h(L[H^@Y=OUhKn7s_jTVpc(`$rg8.An>Pm`Pjucnq?qrhqublB`R)eO:MDC7*&j8gte&&JfN0cct7Qf;o-(gOs#\[)tH8p\M0BZrNlIA"3&Y>dEYP63DjnXf(3+V]t%Ft?+_Kj3e2TSZGr6Os"aR,`IZNg9DEE=bP3_U]52G8:p!aP/4D4Mrbps!FV#-S2b3IWrYCH)j1tRXks4LHepF=<:.o>:+n@o!i(0p!l?#(ng!%&O!8riu%g%8LaC>)&h2i/!*6l\ZhMf]Am!&,TIK"RXoQgK.Pjb'1:I)c$j[V(&;^3-/-ZTBtE0$O.Gg=_@iq>L-CfM2roSk*_@.A2roSk*6kY2(A&CGJ+bO5r/3/u_1-XiT9TkOWO6Ku6=4^r=?BF(qeFoh?.0juN-@CZnE0>3MIFK4elYC*#QPhf*s;S(O:MDC7*&j8gte&&!dGtngu"2(!k`jflO,!@,o:>L3Z.)[Y5#V.;qM*CPgJ,/0V$2NJ8BBnrMEBa8Vt*S,7'sgGS@FZ\`mQh^RW=i3g9PrLO4@s68BiqSUZEFCpBWY3@RH0^p3GP\ms()$L@6W=%fuEne,_8\:G-GlUM4tZ?0+rsP[#kk#!(Y*8!IuEUK)d#+Z5>fM2roSk*_@.A2roSk]BeY#@nge4l5ephP%\5\U5el]@W^h0eb9ien/!%sT]%mU=/RhO.\jo0GPG!5'Er"9=ID%g%8LaC>)&h2i-KGU-GIhiJ?MljBN@W16i,[`/(Ypt?[L5*CEV7`,\SY[Yk-JY9K6%t`C?W=BM8"8mA*s6^Cas1go[pd\Ec8?Gn$::@Gqr\j.^QhAP7Y=#m5f4OXOo?UL/G7YH0bs$HUkZ[:[/+:IZJ2]eY#QPhf*s;S#ON%"*]-/7!n49jp^EF[%E/"+q\T>>aaY>IG=4V-6gS16_a?tRPM`Ppi'`UgZdhX-R\jFcBuQM&H$o>1I6M?s7Sp7M=mkTAO&#NOOte66us>o9+m34^rhq[2EIe\\B2m`a-kVoAHSWsPd7b3@.g?c#MD!8J([+96A6Hk69'65JVEmY6*#^a$t\re>e3^j5"MVjDHXdo:&\V>^s6_=L[ioDA,lf4K<;HPOI`#Q3l^D]Sk$]kk6R.f,no=`gJ1U,,a9M_c(g@obpXCZn:7%Nsm*:!dn;;F"AI8/T5XnhpDsmYFAkGTd:h:2M$bF[&`!!USc-4ITBKG.Y',1fVI)5[jg\ah4_3QA_1,#,6id_?7@D&c:=G=&f6st?!Ppe\n:]O7\;^`q=0#$Ei%N=7G[g[jRaGV!S85B;7?4%UE\-MUeFJ,]]7Rh%p@]FO:MDKBih_WWc8ujkp+Q)7lV-W:mDlD0Y!>/V5RbjI*6g_E]>WU3=R\9DJ+ND;IpXs-_RjM'Nokt8F)QOo0Udu0n/P.1`n8M[*eB''QLMC^uFo79*U+3]ha$&#=aDo8gb_1X`'"7/1_USP:&4GT(hd>TsJ:qh@G%Cqhe2?CZq2A#SGhsN"@8No]BPE`+Fl$aGC=_E20kcE\A'1/"Ba)*Pn00fY.a0E(AtW)gUO]%%S_`#CuX8WO+M`j]F#[1dB74OW^D6)n67qRE,Y+"Mi*F`e"(6U\o5#qC1YP$2R8miXmG/%N6\)s)l^I"Gj@p"&URjr`sRhq2+KN`2sH=uGM.3@O?;NU!5K3;5lcuP=cAubgYf-;pHJ-'=["f$[n%#iF["5iroL;SR9*,K$S*,uV$-D+Lq"hLHLf&)hEC@&:^#VNnU\KpV;5:ca%PthX8Z@-=o74E@(JqWfYOV6?:u(.2roVL[8o4eX'\k6Dn,4CeG"4(nU%D4=smMYMBN:pBd>iMG+jLo.4OQ]@Cei+d+G[f\u;EpORQe,L:FSl[T,lOfhW)'-`<<'nZ;-[_p*B%YfJ$FpZadYO=OD@L2Y4kJG=!MqOq317425o>^>Y_L4*ubHVVCWD_YTDbkr50[/<#jJD`YGWq"0/:kG*WI,c0['#Uc>RXm,H]jc*\ek&liiG`lDiWL9U=l]G'2[`IJ-L3sQFc+7nCbK1kso8haB](:=93@9Bg)tH;a.acMgiagDB8U?N;]=jf^q0\9,dP$jpQ=7or!4WO7+96A6Hk69'65JVEmUIr>^@K$.T+s=G4]bJ:\D;PZpWP5#Ni4YnT`Qmdd^-2Qp@?&d63XLkm)sI.bAZS0d@-`4p`l]SXe'0Ln^[ep_0@p7R8lCa7[pF(R:\P+#AML($bT(*WGCp4olMq=htr-8G:p%5+tmg)$&gZ]Me!'nl'\W+7\l-JUjq_o\^)Y]qRZZ-\JX`'0>7oC/JhcCUJbrO+`"Z3dTtigPF\c>?a/?)>.VY3,l?Yge@XA*dk_ARe%'6#_YWoL0NsiCd[KdZ'%mso<^,4Z5X%D5-WfsL$($=Q.X87m/FIGt#Jj%mq(Se;cYqBpeERH'hX0tOa9TP.A9MTH*tH^sfO)_3eK/T5q&hJkRk&ef'N,V(plVsU09WoSh05\MtS9CdpjploQ,+f^Ggo\mcQ4Z+uh[,l7/[=_GHR[`et.D^%!KO76k9id=SQ1W5aS9i+5g_Tu-d5M@2E/'$O@C%T"Kc!!Yu,o8m@g=)\GDIWDaktLYOfC*;SKuLC*Nr1GE(!QtdH2mK"_TO;k(lZ6-5@L>/7!b,8,,[JG/V/!UX\!n>7!G]1D.B,^4$G/NYPM"A2JYQZmjhK9Zb:^9DMh01'@o7d#?`3E4`WTR^"6o#`!OaD4JlP6mQZ.T1a;&Z[D\+:"^$"jS<&_^KNWs\[&KW]=hMUq&jp=/oB3iM>]*^bm/`>s#5T%K=ot,a]upmRH`8dgqMQb)Kmk=sWN+I^ffkA3!<@b"&-+[V4pV-$Z1[nfX"I%_[*2-4[56F+OBtIfPiBhTSaP[?$9Q<6huEU"g&IP7#k988#f=F&Qq!SVQ;k>tBM96Q=p.]"BLE3^1c-s:S5@ejnTM@6g&1\gBFM$3*u`n>g!8Z2o8CenD6-X*&(9-VHU%.r;[&SDQEL2,0j7dIS!YJZ!h$c-sNV!,01!"T"*Q;0M3k;%VIEOVtJ:fITcP)-**!tTu$6AK1NS"?]heEI#rFRI1sBd^UCHEbD__*V,^\p?H?Fh1%tI-]J&V$=DKA+q-`]O,h=."h.N.QE+_6)FcZLcW5o4+%g)6017L<]*V%;IDi;>P0OPj"#V@&J?!)=EJ+^U[6Ttc^ipWK:h0-\%>/&/XMKdfIX6gEZ"(tC4-L1Y2Ug"H54Q%,F^[I);_$3:iP[l6a8)B&AVsN7aRGROLm)Y96o[8.=(Aa=)Sp("@`BKg[nn#F&OMG#Im2<7K-248`M4qDapfamfP2_*?Y-1Oip<@VVlHZa3'-^HasghVaRVOt6@dmAAXNYJ=YY.3GA,G>mC8+8KgNY4t'`hb?=9rqNShg%=R:45'Z7;MKQ1!'!;/#QPLBp%Z)[Q)3H-bf\iZs3(/Xl0piS*ne!3[(fF:L9o&*c(^HT%LJa"#((i5k]#7jJD)_=\RDBlk/^1ODD,q\u`9[+];=F"jFcA">R:X,Gq\,>hH/X?hL.RY),,eIn`K&tBEqmWUnjqek&g('D#j(N+n0*nd%jF]d!oe5Of=/42X9if*bHuNM67C_OOB!X/J8[UU9MtnK@#AMGRphU10,qYq)fmF.&PO.sT:2q:dQ76!O9kd=[e]u5@)-PbPO:Qpok`,GuJR\XWIsZgH^'25B09S@QNrD6I8.b>Xn-6X%poQ(Bi;ZFF?bESXA+@\H)=m/q2c=k3l*c5/0]>I;jKc8pUDgom+WggeNfXbQJ4.Ifr7p+MDcd.9f\!"NC*.#"In4f>.Cgbt_KrWrVf44^M]gB':]I8_jb#,58UFW9CHgGo0Xn8;49Md7E+?f$;QVmAmo70+id?i0Lj08rg&.9[IcF`=Xf62OJ"+-O#DIM>B,>60^r:Y$-V;Q8G9n?-n3Mu7I*a'>`q-MicO'3'6'M4RF]!;%hds2f]5HkZ+LX>/6pjCq4L/'pG_/c+B]*<$]hnA+"oTk1E!jSFJiic8T3,MDC2jrq&jp7ZP@+YaTo`K_K]F=a-'1]a=QSB.Y]Ds)C''IpYBMmdB2fuh*qCs*QSHdJ0X--!$DlZ(dE4'(\9qXH;L!.#to#V\St?6q&j\IecT**kiA"Or=*C)E$N!k/=?\^DRADp:^#DXJBR-qUm:TdtUH=-^-dt]>o>*;TSit%j=:Uq49Rdlsf/RZVa-4L@%J_,;d*14fKD#Pj$BBsu7XqN;Z#6A_r7]B,NNbL+%tZa=)6Rs*+&l"hpa7f_$45jIbimL=^".NoGs:5k_Fq;'aJT(AH2qu@Hk69;69YYT&g<2'F*D-i+F>6h`70ar51tVs\g]$Y?@7L$O\$;gYpiu<#K'9^]Nn1:>gXn"2jql@kkRF'0!2LKFqHG>F(l%qUPCr^WS@!,fC_&m6#o_@($#Fc?hE"JL8+&tGiCKZ;)EICbg,KQUuf2\%H4Q1mC[C=i1A3D8',Nm5;'B*_j/;"p)Ld6^/sYHF=uEOrn#)*UH8i"FPUc,at!B8bp+l)WL7>p(_"E;oKkJ%;]'KiPW(Y\TD"k!32N0U-dU#;-8c[e.d8%s93Y\Rpe<,!5W@(J;47I1hOQpNOl^5MJ*2c'D"k;trEcth*qsp5IbnDS1[T1Ig24IF>qOf:UT.g,jfY&XT%.+mE\2fp,d^H_h^Gj)P`QF^&<^.K,S4DiYqIQEP3f4LYpn?L8Wk@@"l:ie;H,%g1Ejm%7V$t_F,pf!7Rm;#1)1\^0-`+&FZDlj_4bl0("mg%>%Clahr$26j3a7uqqhe'iqS7U&0th+H8)WOdc0*5:6_9I;:XUn(Cu.G&A3S9D#3)X#:g/e2lCP*?+q\ME^!#'CPQiXh=/VW'7VPS/j-6!=5XHBi)RA"\k5Re1,C]o$O^0BA:H0_nj*7`#l,SC8;&PjcWuOR#9BV2SZ?Vo/?NaLhc&#t(epmDTs:&@%akP\UB[L8g72o;[^l?>CM0hY9Yfn\g"OH+JoYF4CUYgGX]_4/m:dYfJVHWAb\!oJ^SD99??L[Cs76gA>9RR?ETo"2_F)#6N;q7RgK[XXVC3Q8O^VM(/0-T@r@rc.Rk/V\n-IJp@]-G,RM3inSUIM0N$$=Vo(T4X]D3UpP#L];Vj+8mtm/j-l;AFKnn?M1r\+=D[I#@m(/=kE!8jHU6R3eK&k1NQXC0(Gk&NpIbsg)ar")V-bI1YpmCf<8Nt,\VmcKrjT\PZFim(_0FnQ&9^8.ti9]!#S$d#QPJ\MuId!6Ttcr7.W^&m7qBslfo`](<8ApKA_4@"`nrrq4YYmikA.%%):HkfFdKVlPdT6O:QqE0pi@mAKef+nsdCCo%3^)^RkJ/iRQoZ>!dPIdEGAEGh1d[Em+b;K@s)J;\8^P;#EoMTB2]tPW5Y3A6gFiRp)qSNfLQ8TTSt/lfoJ(RM3+4%MZLNIW[fZ4;uWiB/;\3e7A!Iu?SK)d#+ZKOMs2<9Ai*_@-f2roVL*k(NmQI;4Rqk89_jFcAR_Q1QX_[HJ>Ps>;40E1#C0)i[/?M[%[_uI7C5FP*3=dSAYjIQl[?f%tje/80;&c"Ue^_XM^_K1"#QP8V*s;S#O@B2[X!&Pfn49iE^*+R$=4?@j.;^LqZ?M-%/U-CGcKrd))=Rsr]oWbVQY7CV>ogIZO:R6'5:?C#]Q'NcZ!P@)nj-%"8CNua1ZD27dOm_tfKRm3\j13dg\ILKY@h/!b*br#q"T)lgD(A8,N$3LjF[HJ&Fr*eO9f"]T=Z$45lcrkMrPcI6#`A$h*aS._Ucr5if;0s7tCh,"U;;l^WI$]JUA$A`9HjA988)+4W\jl\l^WF@JM%r3SsSNP:nJaKHXnMogADR$NsP9EIZAhKETlV3NBj/hr:Lj2p;!%CZL*J^_$'&!0@IhoUi`6nj*8:,`C\%nj.5K@I8#-?&W-FT=At5:uS,=^2igsR<=K3$-3@f9C0jV\'04RUGIOFZ!P?no0KC+\j)o9_DfA6r'>_3pt1eH-hnhX4B#0W;,L2^(SYojfg8qK*4JeMZ*E]`mBZ057Qq(X$m?\q!/Ze+s1,0R*sD((Z8fQA*sCclSa*mlFk%kq8),85U?"_%WWms'!$Cbtn.L*>p+A-CI+\F;"j:g*D,+q@ND[^V4O"b4)01'%T#u]W6#&eL&mH/fn`r75$hfqZQ5Ik9ehr%8p%7mp`5Dqu_6U@%eo`t^3K9PsR2I%een?mX5,C*(9YJ-QNK&qYTL"#L^H0)&$0HkT[CW]N;lN`>FEDcsYn6sOOUhMDAB:MFgsqJs!dGtn*8chgK!VhD[9\0-dGbV[c;^N=CTfBI+!em)]9]Oi;[^I_^V9^!A9HrVZPaR8/]$K=6#':VH)LIuJ!RkE".HlJEh;F;)s%fNSn:-M32HhP\Cc"Ul[#QUG$q&fZ.KIluG>n-TPJ4GdAnI>::XI+:L>WGW+[.pT0VpRH@pKGhLronm_21j=$*s=6*1`>1DH>goQn@[bn?&L:\X8#cFSu=Vh>DcA-cY!$jlHrTC>oBJu1KQL$9W0CE-)r]`$PC@E$`Ij(Ah6s\=XPH>"i\A)"+/Ht>XmfKPB9,6_pc3f0r['hL!B:Al$.e[`qtKP@Z=N4\KB,_Mg,9S7S[]OCJ-SD)#QPJ\MuId!6Tte6O^rHZ6Ttd!d2Vbh?+G=Rp0+uo+Nm!S.1t/PJ6e6IJlaDb.9I7q`aY7C0mR9$]$K4:SgZU-,9JI_\EL09qZ'%66juiNi!ch,)&[-?3/`cUF_&VBaTl$S>A`GRYjl/[K*Y93,pk[7[=!&u(k5lcuP=nJ>u)Y-/oO!KYD)tH9sP'Tloe=RRW_c*$UIq_-/hneucF.iNjfjK7(ogc"AQP>"f7K<1IYBDId#q!#qc2V:L7)b0!=HQ7SSS:p8J@77CA7`lQ4lNC&AURJ'Gn^Hi9LuY.of3mV9&`.6*]k"m`SBY>>,?!PanZjX?oE$GanO)q7*poEH%52QtS;p*Ef[inj0"a^><<_W)+mEQNdFBJUA"/,XN:FkH9Pe&ATO4iqS7U&1e`i*fR1[oZck?G7P`E4B10=DI,$'Xqg?*F=*Yq6#:/b+N4(V[thF8:$2roT6@/Ifl)H@7GJcI/b4p^'hH;'ndc7/T)nW!(@UTHLJ0-BaNgMGY7@,I`-O1HDnG_96$H-pB7mHs<%mj;[q]3A2/+>l#6e_/WT<%9sj3l]4\=dK3j/qeT`!$jlHrTC=Q:+rp].lV?/)&sQ-Hd7kW;ZH]^JV5E\K(/OR:44;gu/+B@Y/19)BgCArZ4r\Ptnm5aYdU9HOA`0T"2S(7XQ.EI[>5=?#nI>::E107bC^`Eo*bAV"GTc("!5K0:_<%?-&Gp,:Mbak,k3Pe18=qb1.,=lb&IITP!8p#%%g%8LaJ/^ieKuWns0(>M7H-GE)1KP424Q;Zhs*4A*Z>2JKaTjbB4__05g9q,i<&SlVT631GJ^2WkVm:)\A:FBnng_uP&%Af.hcT@)f\.pE^S"h'+2+:D_PCS-P/Zr#q&kd6#\m"%:Tqs7o[G[&;rcDUZ>L,'+C=dL%Q4K->_dZLr8#HKFhVNP=ap4O[jTf#?p$I?DdE-NCNWsH&(:DJg4b?p#>[j2DhHSUI6+qEq"'\.#2PKQfrSIiEI:,;kfle5;gDaGTu'1V^-:E_heP+$qPX3nf*EE-q&fYkKRD-QF2kb\Y+.Y#Hc0P,>;NbFDR_hfCYru5!5'3?5lb9uq&fZ.KIluG>pM5@;K1ldNI5^$LI(Q=T^$5C2mMaa++<.R$TFE_;S/5>[qU$6BN_Nh?p-lBWJ3hVR?rb6PY*TB?nO+cX+7-i&>k9!@.tgD-(QB]$pI_4e>ZMQ/Kh?_-U[WFn!X.o1pHloYpS7dLVq05'fDYr(p3jX.,UAp/4Rn()N1^/L?D"$E%NK9.Ls",\R]n\ab24M`b&p&%;kWO'O'[ZnG(80qeb>`[3-NEGU\+X\=A2j00SFte%,nSg8Hd-*l(8MsYrJ803%oui%?Nc2uD]oC=1bO!I.Q*X-=Vrs]6T@fAfWLIF(,Bsg]K)i[unj,d5(X.pF*Zlgl@e'HY@9$alPJT$^!.YUU+94*KHk69'65KO_Y,9\452(/57QqZUS4?Ks_-J-!?edu35#cG`Dd)[O/EVSeS\;)UqTppVbG>(1?QrE4q"3d\n0Gh=O_!IQDD&E.RQcYeifYB8=YjVN+!dQn1?gP]U[E1:\_M.QhS;j%n*5CLj$&Agn'M7lkjq?'O-4/',t,bL>F^7\?pRpI^T(N4Ep5r%pPLmo-8A/t'k['SSX22Ag6ETf\Cp]_LGuj_YYrin?V<-k9b-SeqP(0=5Cbt\]OC"R.[@-k7.*JkqcpcapiB?.j&8/=c<8r6Icnq07JqofKHg!g#jiSlNAFFQWq-[j]!#H-R4mh:,e#8'EV>1=%i/_Nk4nQjfZi"eF8&prR1>n>ki2ggV/s*0!RjcPeeJdhDpB0WtblO``%QS^k-C;-:j]7IgU(]>n$J0\Co[3(_ZlM7L9c$.f6*a?uX3&XI'p_*+jgM,"n*A`mE;:Wc+38?YDgpU%.rQ0.-uR>$8$7r-PIC[c6Cpa2;2n7g:CdrKSb0/UH@3_Z+!7@Ya;Q)PT*F=Wf+4#ri_R%G%s!ZrfI]B*l$I$mMgO+I_b0J4E3UtiiUGM;;Z*C4+ijnV6U']C-?KpkNU^4/[u*98G3A;=m**rsFKB6\084cb!#S$dkX7"t/a!l3/Z+1K92='[X2PK91O,Qg#&%U!IK0,-!TeRr[>9l:RkE.I0T@KC68!^O%gA\a..ui,Aq!.0S4!,^OF70@>>=c/DH<"5i2`#[*ji,;fgtQ6,`O1fK#MDF-kh1]u_pQV?^C3?s7oeKB^k/-ZKA!L$;?0r+q&fYkKRD/2\gP,-Wkp=dNI88"E(,81GsV3gY<4$Z28\lcI_Lm6IfZ(?@rcJ+O@F`P*@P1gWipuZV_e3KC1:_p5S.Aqec_+2tH\?TFjg1Kg>!iD7^u,K9$C;81L*eaG9JgJ>+**pAaMi!3#OeF]-^W9s(+\[Z*=Sc;+Rd3.rB,RY)u1V%;$8O52fhSl.@ie;T\KA#a+jgSEu09L+(PQNTVq(GlFgADeL00Cr#p3]n5+C;3BH@h$BPc"!(4g4>stMd?*Cs0S)Gk]$2H.P1OM?B+cG;%VlHbSFb/BQq&fYkKR==2,Qdf"i\L'jHM^=NODr^jf[TK`6$ns9jb&1QA+[4"X4R3(a"CVE)&3Hq8kB^IlKMdR2^@E;S0*jGYag(p3:upE=7>W-c3G:l]',@h5!<(GP-%/ZeQ8+L\Di#\$:p\q3?&QV3MLTb#Wt<>gjlfj_UT*\(Ik%VA_m1*4:n>MI1J]gcV,f.Nt!NiSIl"g5lh#LO@J?[#MJ!I!j?$a4#m.'/cd.r!jDr:e:bW]g/h-#YnSg`?pjb*#>52b4DO;lD]8j,P>M"50+`75u5o`h@X!2CbZe9.17;kVlU7/&4/Pd!":"mF9D3+qHh?.Mh<,Wj91_?Q*DsgMe$C1g!0hZOqJ<'UJ.tT%fu_ua9M`2,%T)NmV5DWK4h>4Y-Kg#?u'C.I42#Wac\0[G<+G9EEogci`M+rWpAEAjb%Yg6>,D4B(H'_2Bqn[-I)i4?R0Wb;>8&WfYQ%agq\V%nj2*e4I7b)GsC6b[A9%gdRn(dHWU4PT,q9e5"K;GkC]enn`AiE8'f6(fleILC9858eLn*c?g^_\&s,g7D+ZrYp?a?qGC4Ne+3ZC??"N7YZ\um0,lmb6B@+q>Dq*UIQjeH6jGV=R:=]RM=GD27!#S$dkVcUsp0mj9SN/dN-RrcOcOA,.0>.X1=Ai&FP5/Kh=rQ]^]a40p!+6*.+94rc/Gd[uD97/Z3\ch1.&5g$JqpksV5d:8AX;B"iUXfZ4$r/hK;@TE=,WEh4oGemkRU/mQj)GMOtnJm=2"gt[X2J-0VG^k`JW4Arsn"gg.k'&L$ChiF4;i2=_JiSr(15C8(""=!\]5i+P45;#p,5S3k_o2[J$M$I_?jQS$S*>q/]^EEqH$dIW/SXU.V-GW"->(AI0JP;EFdZP=q&fYkKR==2,XN:FkH;7(5$1Xa[$Jt-$;7$&%?F7:\KZBe6ho^N;J8c'\mfobCCI#uNn)I'har2?-U@`65(+hV0"Dd9k!*Thi=@8,2i+a\0,,6\=Dn9PN^=7ou2kEq^%YhpcaK2cj,OAPtND2FZdPldF&&M`7H3Y"a:`W0k!k=feRb?=Nn9P7H`-"MOU\Bhdu%hdVh7HLQ#5nj0^G&'C-YCs)+g'T_C_^L2QgGWY6V!1"#g+94*KI#oMHg%_fMiKh\>ipSkO)A&7F6tpB"P5/L.[=t(RJVtWm"9=1<%g%8LaJ/^i7>rRWG'8nQZ15qCD@<=e%@ohSa^jP[HoCm4GQ15Q!;-d2J$S5gSXa_*bEqO#o2\[BKgCArV$qC6&`;*f^j`p@^nI%9[4+$WR-E8rWgu<'0gX5VtgiI1k8p2>FPIse''C$sf0CQQ"5GNnW]HubcWJ%o^ACGGQc/C9_>]Dqu?4Lir.9WLr1LL-L0JTCq8h2)sp]b&tZe`-T:$5[R@1[]EAp&FjBg/1.bqH1Fo8r4ZVPOM9ZZ/,Tfp5Vk7p"cG7`Y*dm[Z`!N^K./KdDg.E`8&115($ONk-UM%i66%oV_LrpcW;;?o/ghIQi9m+j3kmaS@*a+CYYS?oOl_D3;6[`5f$XfeV1=j4Z=pe`O=cRON>LmCi-F^5%ld@kh`c!*6\X(Gulbaf8+83U,2@++t=a7>V3E:.9WK?Q7Y2(35-OXPS]lVGIuhB:V)U[-S;hb=J`9AEAW3a+WggeL6)p<_8=&U=^Tk-S=P7QS+DO/UY8SreTmU&gk]=^5,N"B2?2C_qZPGiq4pR3A_"''p]u0*hSTkT1EReq9cX'U#NG;W,:moF1-;c$Qc$$S31a?C)OY+69[qTIXC*=Z6>Y3Q#h6ZGM4=**Z!1l\a'Sa\d+7$\B.N8Bd49k,Hp_JkiWE<_NXcd]_Cr)qG?)-Yb3'.:jH%S]mS.Us!;P+EQO9Cjb#B+'AUrWnO9n0?-^R"^u,L7%:'@aQ2GDFBd:6SXDp_1>NYpl2V+SDONf.k\a'%bYmE:Y=ZiXZ#EZpp%JcZU<"2mqakJ'(4kl+4U\ItHcK[]mO:MBm6c]M/6_i729#RB/rq&fYkKR==2,XN:FkH:\/@@8nj%*#`RI>&EYhLT*TlEkra>)jSbO2%q+F[JgRj-l;%5*a*L2o71&cqTfjd0n.^Jfi')Df&S6ho*BHkX'Y&EbJ*#@ujF[FT&[LUbG;[#'5ld=I,:`9/cPq9*SYK8=g5&&V9,>Q,jF\(X?81P)c10-P(\Esrjp*f)1pX7o^er/X!Iu?SK+_]K)t(2jpWN'dTD&&UqqS3d2/>I9!ZhO@+94*KHk69'65KO_Y(h<8]E'&Cn7(IlHg/L078KWk1Y7$l6n]TTc7EC0SP.&`pkF/mb'MUOa,5oaP!Rt6hk]B[]ht)^$j-bX*s;RuO:Ot7i:LDrBs?M0BM[KV-\G2sQNlA)a9M`r+ln7U+;(pC5Bf!GKA[3?%CLKNFL9HVRA5Y\iqS7U:n$O*Es[g4^)=Y:T!8s*-i2B1iEQEE$i(nF0SFgMaqba.$D/&Nce^96(HS`Z5H5`8>oeG/+6gu/<#6B>ujF[FT&[LUbG;\H6!$4]Ia#E(3Ots#+]_pirTG*9k99kj`B?+.V#N1!N#6B>ujF_L#37>>2d9aZBdG^h;h36Zr9*89VjF[HJ&Fr*eO9f"]T=Z$45lcrkMrPcI6&?_^jn[a.igXWKc.cHtWaT0RJ?'e7"9=1<&'Dt`EUHTn*Pnl0IFL>1q_!mcUK&4"AR()DX;L)Yme<>q-J_9BJ1k'"^d#)/[N6>+s5jL)/KOcU(T!(4g4!C.i>d=:S!a9M`.Ot;W3a9S,0c`++u>SD3oO`mV]bc#]LF`d]PK7"4qK'!J_s-3"KJoHg[[r/mUd;)i#kVU?cjf\H*oq&fZK!VFd`Q*;T]rXj/=;p`r!+6*.+94rc/Gd[u%J_,s8!:j]%JcZdOjicnB14nfG6n&NG3/-i$n,4Z#N0L@#6?)@WDZn.#AG^Q/R_qXdi]b)!'5c#G/J0hFR$maff$sdTJ:%nnK'p)&[?2!>-t??qkVU?c4\#0VpYhjaK(H%Y7tF9tP'(I=u)Y-/oO!KYD)tH9RBGk[_/H7LlDj=[ahh/?B7^J5"FT-qFg<5lb9uq&fZ.KIluG>n-TPJ4GdAnI>;e954$e-tSY=KMYA@js:-mdeNpIe*c\c]T%R;0Z"HXs_a`'Oc@.H<[YW[PEQO8D(LgNB;&(N51V])q+TPOd+7qH,^S?fN=TR0*Hk68p69aTr/Df,iHhR0MSjIOY!\;=s!FWmf+&Ls9OG]*[2])rr/ckbj!(_cqrTACnhYfZ@q!>?5*80VIU8fe2(J/s;1q*0o5%l^>[iGl'&"O&#&3j%Fe4H:b4"s@+loYohm_b\l4$jpda.eKSpK"uj+98KF<`YNoI$!R[mTso?a9M`2+rp&9hmRA@?-)Q0hZ)]c7T]6ERr:?te`Be*X5;W">"Vi[iiDl_gXA@_&8A_dH,1.I7,4ULc0FHTP]o&.=AFa_!mAdm&"ej?$=:$o9.tHhH$8;%Y1lSL2BX+6&rM.WY2!<7^8>aMg[_rHE3]p\)tH8p?jQS$(XhM)OgU>,$/8V%friH"5':6(898BUArJJlXZc(9"AXol*KUd5^l$+L`7W-9T0.D)j:[Ob!epJdf3]m5H1-fCf=9o_fiSe2M$eEVKdC_$oo>nmPo>13dHnJ-o<43.*pq`juan/s8#BNg=KdHN_^U-5d\q;;c/MeKmbca-Skn_$rnn@t`O+[f\;DrYYX$"#b6.bKM!#q"1HFsjWaI.oAil_NZGJ3,>oeH3+%KnGi;f`,!cCB\#%mLI462!%X@tO^TIV>YYe?nj*8;#\Wh&kN:pT79.)%A#G*7_uK^shtb9qP(fMlIG43hWJK;C1dpXM)!:BTPKCI`XUi*3:iMsJ;-MXDT2OFr_+h"Ll,kH$njM0NTCYoo%@Pl)VcnI&A79Rhk0>2QNh2;+9J$h5Z]l@LS\1RlEqjIpc6Hb1_s\:?.*96Lbm04T'<-kr_&&k!nr8Z*d>.MYOike1lM>]#X'29H>gnj?>i"90j^;EmI'C#b2_Oo'?uQ7JT5;KbPX_m)BC.!C6WQM7`Q3Bf%UBPS]mV99JF#4QF#LIMhn#/$o$XZ=+kNpQGjmDrHu)-_e=Hbfj=k%Zqo<4.%oJ>[mK5\I@H17rFhJu5ZPd79PL1n\s-!WXdhUPCL(+\[h6P.ZRqFI*Js8"^io156.8Yi[53I?C.2'QmUDqm9..f9r>YG@WLP2bWBHuMQUj,^dma),bPi7I^@jUsi/nG&)Q+<3LKFLZ[)mQ\,cs4Qb:_^#CZ<:j"JGBo#8"\2]j0cj:]61VkWrBkEjb?F)K\DEr;Na#G%R2e-J$]Ja:e&0O:+LliYO&Y=;/=/-S[j]\)`#hkUBnF&NBg^6$V&]n?n)(8Ge>7^\Ia8^FtBKpZCk6e4lAJbcJtjc\-ZW!$'M@fGGJDab>?R#moYPj3)g&nj*8;#j:oRPJc=&^\_[m[=7M"`e@ZcKnA_#8r^(EK?UrG_N_`F!M/&%Dh9?0-=!5fQkL[?g5g-*El/J":1n!QMGnS4(abCL1.%_$0p%56;T(_[r-$s#b29M24jQm?G=rc9UV;Xcrm#6DV[-Bh$'&+!0@JapRg'eeBhdhYQ'`;iF(7jfDO4'r^X8ma@MZqq+n10,d!o<8#OgUK+BlQaQ^QN;Y\(,Gu^(';RJr_a]E0*IDHg8;[W^_>Y8$GSW]SB&BmgdB?E=YHt0j>"#%.u4Nd%5Nc&a+mRI1C%dkW\MKfQGjNJ85OMP=0jP;_[4N5J2!AB%Pci]XObW/rhr`T5PA8kW<0#`A1YR(='69Y\'=#X*f3c_VN*2K^D:O#VBnq)6#[.E"dMtoI6s6IG4p6U8RkmtOWk1ecRq]56W]Q^CM:uNR;oCbV_=AnHLHF`d\U!;Ra.hp.a_(;iGd5#2!BF`lURjF]_YpZ.84GQ7EK#6B>ujF[FT&ME?5?ajnn=mTq=rL5;I#K]rJ[]8hR/FLm,1?X?LK%50]Ae'k+lWp\%R#jL9"3t>O3/B"^PX.cAJ5,<#[Uf0J1F-ca8_=+nD4Se,lZuXU)d*ohE7%VKUN7;%3D>\/2@i'%RVn]obhu8`'r,F!uHQXDpY;tWJ6;rJSL9D^;gg@;#ICu+nFTA!8q&i5c5:-5k[=%Jad6D5K*B42t+Ac'j_u83dqq/YZCJQ`>R+6L?]m,Xr6HVVo)AVS$_%Rq5"9=1<%g%8L\>]FTAnL1ds7hYsP.1DJ%os5O`r@_VO8QR8kb>RFqgY+8jmW^L^[k3!B%"E(jkBj%*[-@smt@C4jXD9t*(fop3fR&)/2cZk+41Hk68p69Y[j`2mioH=j]0rU]W8)6M2\ogjZ"qTiAM>Ns#nr3*lB.ibYhi!oP$['VYk'ZTl$pT#^EU#d^tPdjDE\.Y9'F!Y_n_6.Ff%Q"ESYE*k!EW>]a8Il14:N:89Gi\3#8+&#[hFg'0cB2Huq"3b_>L#V8s44Z$`M_ec4*ch/$K(aUnFDnG*WZ#/lqH[k00cuu^7csh-k>/Za9R8>o7HdUoA+7dg\rhfml!>]FLCD6`Ht,Db1VLGMg?l6K&p2MjCM`\5rTouUFqqi+r1cO'p`=F(,\;[nf:=8>,&Fo'#Rr7>:VXK$'2'2Q:Y-'5bi`5c^"PHVl:@qZ.h?SCO5HuMhF:T%Q\cO`4X\bRBD0=-O[Vq+:lpCd9T9W'F+hrc6aH+?#$Wi%QqTbg!Y-mRg:qPa;=[]F=tSt@%V4,TXFj.%"&7?A#]/&O0\5>V-d+R`MJ]B8Y!A&4\CWnQ!cX"EIdAUS,`M?dc[4p]?01[&`,hssA5&Y(B.W4RUhWF9A"%fu_ua9Mb$UUs<5A&,D!@0qP]`V1Y=g;^6BQQHedVA@fd053iiFjAjp'lnHsq8XS,r8fVas5u>=5P\[0aTqk1%t9;Xs8)&b]<3C[HLFJ["DJZt`VH]@?uUeokmf%InL=9U8k/e1SKVOo>CnIg2]j;YnBFbs#_Ts`$j=ashX'IjY3W+4mlUkD;dSU.Z=DTK:@%;>`[2ZXADsL[jS$gE;@o7n2nfu0:*1j4<5G_3F:L4p.2#%`XVn05PW&*a=?'DUYf?4*'F0YrstOh>Mo:C48Z"/6Z7&7#BI'[mHbgZ8sA?IND>QoD/s71ReHpJ7-E]boL>B.]Hcb-)]X-j>>E4(>Zj8c`d7YTV);d6kdI;KEJK6rIE%D4qRP4c8-oc&i:UWGn^OR+f-5Q+,!`K6]VflQk,D2VThje[QMqYBuR/ijO=^F[*6(d&Z+60Zi;oh!D"^)=Y:I\+@Q^rDL+f$seG53.@37.p.dCUX]u/\F,Lr02csJWloXf-d_N-iT[s--(?ZV/e@-E!5_t=952MR*h>,205hVm.!cgR1\fllfp`1O:MBm6c]K5ggbSNnl/`.i6[j!3]ESaR4F-ll+s(&lW^q'1QRPld0/Xgbm0QF?7m=i<"$J?.Q),-5O\",rT6&R8_+OJPjpr-XTAr#5ftWre?;Hr/gk8"'N%"c=rP]\@I.Dq)+r%_sS;*s1YPPW`i4..5MD;0&C\\t=+Z!+#g(+>E?)P:pb5KE%88DlaO9aj!!5K0:5le>s4+")8]-O8CS#:Oh[0rtPO:R69h7K#Tke$ljf>^#NR5c;dlehXmo&rl!qGT=P,,T^ed>o]0i5gmZu_bITM1YCh36R)jQA@j<'X#7'klL>'Y-/O:1)?Hm!_QOif^WJ3nje^I^DrdT-)s7tRMc'NVcrRpXh>7_;4q9/g8bNSH-h]3d\G>BLd;TK(D_!-pk9$l5WfWjFGJj,\XSpW8d6f0!d"3:^u$*?MjpG`-/>!CRog;:EdE"h;bi%/_TD$LCXn4t_3k3[RToo+$!+DJn%!Iu?SK)kFZu.K1`-MX'?cYD^R/q&7$Sg!r3ruOj9W3o_=U-95'1`"m``b\r[F(!(4g4!Sm.ZW@0C!>P!;/Eb:0UAGON(`5CANI^Bn,PG4YWitd=ao0FuT'J+!P.GB8hFI2q'I(I>qofk3LH)KtpCVKTdItde=)B.nL)T6;QaKPXEE!:Qp@=*r='($QPCMREcLs+VX]t`ggVI/3Y-.`4H".FS+=k3l'9ie\I5PO>X]F32gWR0=!0FItATp)ghS;ACgm,OQWih5%>lXj)0'$\b!oRWfZ:\h1GPo>&-"h,alXoo;bF\`lleWk\tOq>*DBSTip6/,dJkr!2f]TDdH8WRY`B(Ec!?/(iDQ!)A=8#Gn)?Y^:lnI_ci2D`k`g4?Uj!1]Y>aKA[4q1G]5f@7E2^1M\@^WSm(2eVZQ0_@,ELg[KeqWuW3$AR-t!&$?uW(TH`'gR>^=J+C/KiV&"t#1dpJ[,CdH!+:V;H;7`'M?=4dYI24835*`'Eb5f@\kdC\!S++n.o":alF*OcQ&UeTa,-1BcS[^Cq7Z6Fn&pOV)rG=CBlWau`FqHV\dAFV15nH=>bATGDr+h"-nnl3[aqCENA_.E!Z6Cugr!e`DG8n8*c:CGfdYLK!D=8Z&6q`#f7]BB]Q+dV\[>kUJTY6@+W,\?!#VI++N>K+@#,b^d+U'@q7,Ah*R+0tf='&fUubW:Z5b6$eULn(;a],_n(p5%Y?DA1+FlsK50-dd5ipRuXo*()^dis(l9fpS8`RMlrcUCDuD,3T5/5XCm!Iu?SK)j,4%QHmI'jT$96a,<?PEp?l?**J:81$h,+.[<]Y.u&E_^`t!EA;-Q5hJ^(\k`%T.EI)5GL3721n-\6/i#ZgmHHTJUGbEsplfs!)1MLIdS#jlF9_[=&'+tVJ0j9/!:HPZDLOc+-D')@dRA5gblM2/e#'Ha^g_,XenErCmn`+*(l[8Bn]C%U(N,E07"m:[Udd8mV9fJ!BcMrNc1MR%lOnVDU71bms*NRZa4F6.ehBr!&%LXqfS%uPr0(kiCM.Q_7G[!mQKLVF-Bi]Y.jb538&hEpgRjVi.&rb:,88N'0/&7_F&t^m#iLhUEI@A.I*c7V7+=U1"rnmed*i%=IE2Z0D$kRrro*!(#Y#m%*:IED(U/R?&=@,PC%fmD(OW9#;E)_//oWc1K)i[unj*8;#[-el],L6+!08C`j."iq_5XuE'.#"/GA>:cb[Oio!%\NK6#\32?LsiW5u<$WWNoHV4b`)1!T\mKb0\@o@*%]*3BQbj"`K$@&-*P64pV-$+cH>@=$o.Wi/qPiI$8\:fJV'a4*NWuCLp`$[`2b7mCNBaZB,Ru!(4g4JK^RMG@#9\IidpBiE,ZQLim"]50*s0J0F!+!$DlZkWa8KjF[FR8J]?*q7M>e(n)=O>o*Ln!Vmi<0SFfbfNMBZ\DM=6E0L3e\eq3qaLX3I#NUL;^_$'&!0@IhoUi`6nj*8:,`C]P*:lJfJiTMZ6-ts=naT&=!=)L&R6a"B!":"m"=[.W.CWi*iLe\m/3Tu[CD`@*]8M#_H]sYWDnJ>akj.Qp*Wi6)4pV,s+WggeM3(1+\Z6kq"Rnsf353Ke+.Sak3N"ha#RBh-hT'=^J/f^S"9=1<%t[1X9FpK<[$Q8_97H9!l=F'q?!U`h84,`H_id+mnj*7`#l,SC8;&PjcWuO&%iUBRCU6/RXbjQYQBkb6Y.?/Wbpb#O'U1-$Q=1Sna\3W=.U6OM2+h0edaT0elWV$Rf[`D^MMt4&k*$6W*8fhB%h;>F#!NCTGFiWqg0-L5ags))=.=pO.ME7D=$1&W-99h,EMi@5F>lY"K@BFZ1$b]jZc0m2m4-f(`IP_TAF+FXa@Uc.^OEbP7<_t9O9C#89l^i*TtG5Cg-Z+TeQcgjZ$B_hdhr^ed^1*BDK=5c0DsTI;4i>mgS,kO:MBm6c]Ku;G:\$9O3V8/f@7*8`g2#J($8<4l':*R$Eruk$gFinj*7`#l,SC8;&PjcWt10B(kHa=;mr:Ci=1C;cVF=B9mI"GX%sVDU,>$c?4h\N$_J.Eh89(9%pieK+uk\DCEL6m)fQUF7XM9d&?h"At)]Ed+Rc3U&"&t#>+$!B!O!=>92mK=MjY'L9&GUpa2>T0WcSE-f'R8>Om'mkA3Y#!Yd`;rm;>7C(.M^LYEQNJ2:.9.5`M_eGq2l1?LMh"$<_2!8$TF7j(-YU('eASa9Tg;jhLg.BfGMCXK3mPq-Zfq.BRu.Cs'R_3DL@12sKLXHDCI3eJ5o%WFP7(NFk\a2?a*CO:MBm6c]KuR^slYGNS8QTDF#&s4MJoX%?1]9e]c6l>`M<lmZIN(B?E"nj*7`#l,T6etd3SB^sJO#2RS[g+_=?T!`3AADmH]DLd*G!#S$d#QPLrrr-uP?=-l>HKWSEldr>\a2dB)j_6O[mSNXGfuD!5>4&CdVmA?e9?4BY[N>:ONI7u1q8iMnP*;5MOa4+JVA>EH)N6#Z4<<5[3CQ`$(%pRa/#o)\6emRog7J#WhTa`#4?P;Wic9,JRE+03OKA!1l"IZ5.9WKX7C9IGp=d+b958?o`n`i.p=%d.qC@i5golc2CIh%Q0Cq'On9@f!*N'KWN_e&XFE@$hf;i`QN)%UPH0!,qosSj&Ia!e!cp8]4*fkT1_?X)l-d8LjAW/AKD3fP`T-94nEJ?7_%HZ_(F&:q)juSMr8,JLa[>qmY4F"Z9r9F"*`sArDu'2OGGA,'$P&0&G8$:Lm9h>jY.\L-Lmp,K@M.HH,A."#&D(jk@Xn^2@43:"-U^mU8p!WYU/#=83l3jX2iI/0\YQRXZQNI"[02_Qg[*"G/YoZ5SKod\#f!Iu?SK)d#+DXK_b91uTLb-o,o=Shj=6c5c35!;MeFsJ/g9?uS'Wh\`eYJd;YalDZo5#^_aDGGQM_,FbKgiBh+>s*Sp-M_ihq;C[N=1P8`'`ZSK1UJGjaL?@,Q:1Hsfpq.gL#Q,\8:kM=aBb*&g>:lK=)Xu,,HW?92JtN*b6PTOmcFU^gUT'LS;Q;Z5rCF6TC_"*B_%=gq_6*.q;D1`JP.B4%u8KN"GbjjZm3M4503+qZ>'61+h@O_SX@Mn-_&#e6c]J[)/)4T2=`5\[j(RD,!YS#'t'/7/os8O%LdE"DVTOn3&L'Efc0YQB+"Imar&CCf_S^dJ:gq'Y0<@d?gJ(!nqDH&!#S$d#dVf>dd3UfcTa-J<-W(U.ib5#l.XkFP*;)>@U`eVA'#8B=')TQgE7R@!/?RG!e<\KZi=:6f3>j[#1;>cn")9M^V"^J*"Hi9m&(!rohq`[qA2;Vj/'\B\SrXnaS>HtM)D,*h398e?4If0ZQX7mCEa!@7Hh,"o\9.T&MimamVQV4)Z:-\B_):Zd'NNY&GPb(Z/E-:Y2m_oA#J#5D]FaI9?i<28qjb=953Qe3odEG\U6dmhuSSd)G%s#cbs)[->54'A?E7&8]B\+T``a.\M4^JsObg.6LWWeB3dFkOdQT+I?*2Mk(]',oOETr(1m_"LQ\5[6Tq4K^Ij$GA9AhJ]tHo=GJ1Kh=/nj.K,a`K$eF([4WrV92Y+]Gm-")FIQHRqG`lHH]Cn%7I*c2JlFsrWHhgqhgTn9<%Fq_d$NOE8q&jnpK?6s<>8#O)GGj\#=^RSB_9BJ7=9A%ZO:e$LG@#9\J"H"riLBEqLNQn,El\IdJ0F!+!$DmAlp%,4S@"ChG'45a.G%ETFjcrA7T/rS"8O'6n^b>p+J--NZ@T0QV;iGa@TP`aYH-Askd#GFEB\XH=q^l340EQZfZ4%'crC!*fg*+F!m=l-!!8Pt(G_h%*hMG`?L.;`@M;^AQuYBu.1+14ThpK-2fT$Ob(Yab$#OLKB6Mbsp9\_+Ggl"9=1<%g%8L\?l4jCc4dP79Re=-RXTaNk*I1j>7;aNh!efkWh>A_='@OZ8pj-G\6BlWgoe2FNX1G6)2P4,=3%+KA9"O/'%t_g'%BlE*W"fGE+jJV4QZ7-le3&4o&K^ZB[V95MdSqDa$]u18j2cM_B8ss,'[cnj2ep-JL&"3e_t.FiYHbrKd>4^5d8lF0c)KYmfRLO:QqUd-*?&Dm/8XK=$&GpB/*+Gif4YJUA$i83*L$P.?9rg3gQo3;k`:a:%Q)!+6*.+94rcS,:O$S!u!aK;GE]1rn-[pp+o*a[HI*_7)>h=_6\E5ON]ZadHL)2TsDOa!T=.\Mrt\\6XqN+09DF\t;hDB`*D)7QtM;-9Nc[&[."_..R$]?[&-B[]u#SmZOhdk(C],g_@\tHuoY2^u,LpX0_B5VY2&sDA=.;gKGK_aTqE1CZuN.K.p3sk(?li/kRB2'^oZelkZc4q4NZ0%(_@X0+,S4UuirXQ4:VK+I5o3eb`^P9(JHAp,jM,kJ7RIZQ(:CkuIo-g$&XQ&XcqgmJ\mhT_`me/GGsB&nTI0VTRJ:iLsB\`&n6=#j+!U_j)N5NIIT)?)YTT<74-s8MNF=]Yi/+Wgj_dc`P3AKcZ3ki1H.eocNaO:MBm6c]K55U)TfT5-9PqYL&YS=Jm0lERTK2m8e3ih"uHY1gr%B6F'unL==@3$4sJPc\9XURR_h4Rm<8OW_gQMb11>f8.Hh>k*Zi`#F4;`="KZ1]&JDa'Om>eTFQ+mM$m^!BcnHA1CLIU8OH;%D`@l$3UsKfgWJfff-RG>m;TcoAk3:%fu_ua9M`2,%T)NmL&f9_qQ.)dC:>\+[`<@VqPuRtbUIj)Qb^j5),2H(hp+13q\orN%9Cc.FFp$`UO.OJ,a4i2mPo]bp7a@/TSRJn#I/hnTVA,$>C7F8a;:FhYK`5]ch/8[YG,^-+G4NiR`'%k=&0l,:%hm!2jb1-R_Sf[b-2-V9YCPGD,:&qh1XnJY3@0Jc2.P&l0VdTF8C0fdZLd`ZBJ'j\n+r,8=U.(oCC>?g[l8\jCS033gi`*GWM9XZd6@,>gtVf3mflGnh@PCbCWfc%kdG@SpDR>DGQm^]NY`cC:P;6g7&WLn%V8^DR$1=^V$tO3$736TVH5cWNZN.,R;<3@)@5&QCC*EdfFcK?h)9pA'-3+$j%-3'!))2&_9HbJ\cd4i.]ea:Ks3uA`UV-cY"0Cg?,p494!/?RG!e<\[UAs"kEf7!_j^pDS7fHmYZT'$P)V]ROYeh\)gs>us0+"?j3nU(4`m4l+H'](GCj+omMnEkaCYWbVmFm6TW,Lq[1.N98.BSDQMX>dfXjO;E&m.paH.^HVonE?Z-ICjVT=.IHm9rMb___q!QZ3ko'e:COqr0`Nke#A\C`2%8+LI&>-Sb*,XJ\_qAD'g]p]-NIrZ?7\MKOlp,bUH\kmqt<3:3fD/nL=9E=7GAha%QPUqK`(JQF%Yp*Gt<^VA]2jq2$Kp3A*=qW1o=OgAcP2an<)a4JNDnD?ekLfum/=8NRl?LDr"PG@9[jMAm_d7]UCm=F?MI?G2aV=99p#2!Y&fHd;KV^[ss:EcldlfpVJhMX"ek[^bBgf()+qpBb`RWh25?$W#cKn_VUC2E2R)7-q25s%/0hpPM(;:d`dCbhE\bsm`kK0s.FQ&SddD46lh:IJiCM,;MA2hdh2o.+W3ND'.8nBMec\KL[>cbbLm-#K=-s*i?mF0D9qDS,M:>aRX6g!b";lr%@["uAHI=ibu'hYbNQTm7#99FFS]bf7&6iImDp'd&_!p%=`g0"cr_XWWYrl]eK/<4Xdb6KQo.g,qa*\KK9XrDTXH3$:RO:Qqu)4DFhd.ST_1M^fMD!AK]2<9DJH*X&S$Vn7Y/'cHZO?cALnj*7`#l,SC8;&PjcWuOMK<2*R=PC,1;kBU?clj3,YP"#K?Kn-_T=oR\Np8_?4MG/Y>ICjr2-Vq<>?eLBVRC5TCStW'[c;AoNAKU`AB)_:'do"hE@$g,O,@cjaXpTaOETIW%f[^?Y'IgN.BdWEZk"Ia73Ws/IRa6`]l:(9dU`s*\mt4\K-*3&3DVHfs-8HTtX29poI=$UZ$.c?h?cAM/9ao:P=IjD[1IpIt[U!dtW1+Sm3)dd&>hEO%lO2+bej)YXm[R-4-s/XGbt+EB"mu\N#2&g8l$6C^/'iLJ)gLfV_@0HY1_]6s'C1?&X+/TWU3T>^)[2pTaq296m"gm)P.iGpWNT>Ifpg2M/OGOQC\Ik5T^+OLcu*[+A?d(*bt=5R"`;9/DP=AUtaplShl)%.Z^4ip)5+Hr/]6*eAUP[!A`__%9!+nV@"<`LaL^7U:g#&o\GL;WHEpi'\c\4TY%g!;1*s>s_VDpdN2>SH>+t5Zno:A1ZnB11>r\!!L[S&_Z=0k:]pj/:*N[%r+>e:&YTR4pXCbI1UopR,\$+eEDNM6o)aX(8=![e!:Z2)l#WUpjMi(B3f>&!IOor&:bo"+JM0>5f9YuYNd3qmk*9U4>SNO([-JO,gcht(ka?`cU3[U#Xfq:XD^DnK2.L!:N_.joL.fb>FF>nDm6qZJ;?K5"<`LaLa^Y4e[qSeL]5,86:!s(8?;Afd>gBr5S/>TJ3fR^r&iKmK0Vg[`VLA*4ud:!h-[m'J+sSE(L,Eg'pKXB&:*Jl!!L[S&Nsq?gS)bWqFBe>nhq5,(/)sge`_Vc"ORGT(ka?`cU3[U#Xfq:XD^DkK6KiS^/k#trn(Y+dJ!!bmZ$*ZJ4N*N"<`LaLoB>'H;YY[(;7o3L\2b#]l*)^%*.],:/+Fl%M"4MhU)Q/#bhh20F1UIT8+>5&;WlS=Pn_\#Ks^$@e4(%V[Wm6*gi=a#g/5/+r,M;J0=\2U/6,Zq4\,]Dd@d,gcd>RQQc.Laa8A];c?Ujr.jn,ceE9E:jbA$+94_"5l_*tMY$F9UCRDP/N_eULPJ;0ji^:q&k@Q:O_E%7MJ:%g!;1*s>s_VDpdN2>SF,O\E$oD6OQ)*4Ec:%PN]lL%bq,4!]>I73;XTLd$Wn2*NsJi9Gk^O5VT2<(bI_b\"2P4F(s]+&lfaB*\Cid0B[XU(7;Z#ZI7cLDkRPLe+CFh_rmfqec9nMKe8['bUidmL7tZqN(\$'n$Xj2DdQrGcrft+m!2!7K2mk7eAu71RH^9il6gDM=Bg/X3u&\\paJLZ>n5=kV"*T%g!;1*s>s_VDpdN2>SF,O\E$oXh,)RcO;IaK>4tfS"Z7D:^dXQ;UG_X?IsM+4d5H\,:=P[/q:D1#ERKS0oLn8#D'a=Ko6RPF`4\[:gtn(.K]7YqtKOapY4(#!=5D1!\amYS=bQTI1V3*VHDa,f*B@`j1@s.lVA-WW;U,L4Vsh9!CSF6+AAeTT^gDCO6Ybun+-A@hb0#b24tH]W`qj"?7UCts1nV'h[ShDACT5_#O$e-6U?/J,^g5S+Sm2\K.t:?H)T*qJV[iookb^td,qS".GLU^6ca-Sro\8$L^*K;R*kj0r:=XK09(Uu9?U_$oRL$qqB4^;J)^H>b@^^jqkeokl(J6%F`mMTJ3fR^r&iKmK0Vg[`VR$o4uhSmflq#"rq,.(ST>sF&3pAn&&O8da#j7=61o8unW`Zr==s@lTM\e!GY>^C#X:4#eiIW4*\,t-kNDI-ns8Zq/M4egmK\.OJ0=\2!.uuEq0Apd#D%\AMY1g\IF.Df^KLQ54q;2]apdrr!$W8.&8$%Wrm_!sn?&a08IpX-U(8HtC^uqWf\/@B3Ti06qs_(U[E%FKC6.uH/90sY8.QGQlK5%PZuj\G#9+R"6LhbQK)c&%#6>Zp.F-7,NBRV9,ZCd_X7([9O7Qbu6%'_HBhSiNKE*25I1U#?2;Z=<#9s>!&>:oCRX=)c)qrI(Q,Kl)MhCP\@9Wq?2W\f(*pq!PipT[uQQAc7^q`3PD/E]i@<6Lrc"7@2GG,NK&4@TC?kB1p57]XJ+:sZ/Z+d?G&%Xm+GH0NIa2,]`M*-q(YX.nIR$%6;^MfYAL;,q)4P22J+BFCaL_+QQh*9$\ajTk@^srP8UTLV;TJ7cP7XN@oV/T)r<3PpjObl7-5S/>TJ3j)\!/P=:X6^0odg+]8pNnddl/U^lqW$FXDqKdI&?4lG0WG!Dr+4pHmaBq>UL=VqK*[U7eOTR$KqP6J7!cG0eb%GV+8Kh,g=gK2QQ@UXd0B[XU(7;Z#jIP"!!k1@/G+B$Sl@9t<#:-P*^F[2h*s<^BZY0=m%b>.j;?HJVF\tbF9[Opi*3)-^oJj`Q,S5KKfQsOa;G'<6V;9R4l]W@pCI90H+G`4;&:#Ja$%IBm(HK6lSo,!Xg2ch4JN#3r0f_0A4]LL8%g!<\=Yp*q#AZ'p.099Q>#Umh5iBoX2]Zd+pa64BRQ%HbjC7_-]Ze!=+AEZjmt4OJNa?)&:G*5o!=6=o@F5.T+jrF"qKV#[9-9ljTD#qB@?[:#c'CP-d-e24%i#E7K)c$o((C8`_3tctb-HFLmr%!Vk6[XJ-j,U6VROS&1\2\]Hu*P88pWb($@NJ9+Sm3IVnBY>q6]>XO&(4LmY>X(D:3L-a3kJ=3*:0^pD)@@qB,c(S4*O1ENgblOKu5(Q?APY\,X'4G4@R;0VC5HBDhEgp!P\D,X!8NKg#c-&:G*52=6pJfQqO]l0c#2hS.cfg!onIFnk"i`ts.V\]a#rT$,pQK)c$o((C8`_3tctb+_(%EPFiTn_[3L@q9aNG!]S>mo6t6TFVgn/!(e`X$Rm2+G`2eXIdJu3ECK#^JfB\jup3*/NoGWd0B[Xi[buJN.Wu*J/!DRXRE4ADu$jWD&\o]Orb0:hV$Xr"8'Fa_mcHI`2_/h!=5D1!c-fh]&0K58&&CjPi:9daJ5Gh&F!#k_VfrNJ#QKHBumq0amh#!($6C@',q?kFp$cqF#S@rj[1/.m94Jd0B[Xi[buJN.Wu*J.u?4bcB(=a2-#dc+R&hd\?gn3*pfVffiO6TMg(mX;63qJ0=\2YRV/Pj']hlI3M@tRE0*^3m\*4#i[BR!/uOf)1[s^\[fAA]U.)Sei*Q)Xf>bdM+JHg4b9%"::ugGf"J9$`2KTFV)86luQ_a6p8%]1d!62jD$m>KED>dZG\,?0`c[C4\;r4CkJO\mt!(7Y"+94_"6"#=7Ak.$/m*I,sU5gG^ThL) r6d/[PoXN:IZChREo>L>?O$a96>qS%:?T`@J$qB,c(oKcmFZu(M?.I/UM*m,JdK)c$o((C8`_3tcTSFfVLp%?.B2Au``ke)P+@J=uc`u26qX++KqW$FXYBC*G+94_"5lcN#K0\X"goo)n6+.PGmC2a]%qRT#_U?*RJL42Y**(SF-G$>)$53tJ0<.3!PgZsP5tg+)%g!;1*sBGjd1[>jPjP_9Csk\mUCRD;`*"ns(^Fh3J0;GUG#?!ThfiY(h.?>)AbmML61dGCK'7hPGV&gO"*KASj.)7'9,N'gJ0<.3!PgZsP5teZ;+K$&St/Lr"<`La#XArL&-s6D]_?[LEW&)_c13gl]u*F+TFV)8`*"ns(^Fh3J=n=V0%4aMhmiU(MP'<&*s<^B4pXCbI1QZ3O4Wu0c$QMeK_-ZY*s<^BZY0=m%b>.j)ctKmUYftX.+p=e?hq?m4pXCbI1UoOqB->]/s#=hWd")^(A/d"?^.-T#(7&5+O6\^^c5G[!GkS\?[YUcN4.-jjYugg0VpKmHDn'B%g!<\=Yp*q#AZ)FFm'h6N46`@)sTCU>l.Y#>&T'X#XArL&-s;G_e'ptG)UXt?$>I8%#R4Sa;VCGm4-ec:P>.F18Ta5+8R)[6(C@GJ8qe?"'DV\WSYmcc.Z3E;r/keY]Utq6QZFRLcu(IdHKr\`R]t7A*qMCbqGW;l+b"1hnT*:QQJ6XaCC'f,r[*Uf3!no#`'Ff*SF;q6_=Y1h)QiG<1)#;!=7[9)?>_K2&X2uo\95O(2_UB_8_&Gp"^=1&:bo"+:p*kA*l"]1"Ke!XeArp,X([oK3qM0IsLfBI>Le6m(]i5H:GfG%g#3S1c(rbg8L@N]6VglK`c`7J0<.3!PgZsP6%cAaR%!VU2AWX3@6\^^9<"A[mhGB+G`2e"GPZCs%_^#;O^Rd_q%o\?hMB(hrV%ks4=tKW*Y?BMPMUW(A].]&A;#U>7.k''4G$fRIj#3ga?"ORI*TWke-*]`c3Q0p0R@I)ejq\sr*<%n:G_uK&gKA2)H.,N4V27_o5/OumT2pi'j=J_#CRt*p[@!89hddU7!X'gbN4nY0aRXQq^X0I/J^unC$Em32r/A\oL-J%3/[t@j'YRcYSr^HPAa]T,IgONV%3V=6luP`in3p6:F&/EBD>6F@*;msr/4!ln:,lU^O1J!4.4ugT%$m+K,`nB\F*/J^GU+Kgcd>R$R3EWHg\`2K.GLVYK0adL0_uml61dGc.#uh!HfW*iN<>0k@I%;"mP"P3_4Q2Q;s?l5Kg*!YQ?LGMa\QX>?5o@M4#o7!r5WT,J2O=kWB,D'Cg(m`_7bH4,BrTPgqV6d@scVsR,"'OJ'>Lo77eSC3h9u!B`\('WjT67HHc'Jmd=ET:1\!JGs!/4klAIXMZs1A?qgh:R,)>'Zi!OG*5'dE.4DZ!*s<^B4pV9[_7bFj:-2'PB2qmH*R2fUi1\ZXi^m6pF#B9l+Wn[H8al%C)LiM*]>:eeac:]%b2[r:/iSdt7JZY0=m%b>.j!BAkpK,"XaEA6ubB!?0C4pXCbI1Q[A3VV;:))!6L,Zoi(fr`s-jj8M)io"1Yo@L]$pZG:9a.#=H66BI)QGJm-&g(?3#M%#CignNH_+`WjpJ8)EZLeCdalFh=#+FsUh#HTX8*\qY2D8*:gn]]7^*r5u=0Gq9Yn&0.-CXQ/qkg9h"5L*NnoEsq!P^UP"p![k9a9O:bMtW_Gt9"^e!e^:Gl.,!!.sEe5d7VV[=@N&Dd@+iA0&**hG!hh8,pVr%Y!ZG%fb(G5Pjpq-Q`)4f(UE[FFAe9\IX_Ta);FC\j(1AIAj^-YC41JrFP#^H?h(31%2b6'E]R(SSU:Mmq6@@A[D7)=nf/G[f_Mo`-%)a9$\Ge;:5]qT0L)"+&i[X$qO1**[;mS!U`oQ6%SZYJ7"g8"+YBq-3IA9gZs-Rq5#m+7q^NS=H)HA^2s:$M.6f;sI0"RVlcEA[B.KUL)Zpg-[Y[,1*CdYPS,^H9U_AS&G0ejh7l$@S:T#AQ8Is7oqY"j:.^b=!-!PgZsP6!5rnTp5G*n/0Gj7UHFI_J;o"<`La#XD4nog+kf@JctpIp1Q8QG,N$J8HFc'*$4;^\MEgPM\J!4nV+?p*b#iftIH7E+F,;lurhLp$I]V#P_GB\/eM..J;k#gC8!KgsS:7.m8%%6qJK5If9,#EA0C5=aJff`E>"t(^Fh3J>Els,jOFJTBcCiMXNkR"<`La#XD3#p;(U"gQi:'O!d,?Hhb$m[?dMPB2Y=(FIce"r5.]AWkWncojWI'I*(2)Qqpt9SA1\mB2:\V]?\C(Mu#T!qD15n1FP;,cpUGIS=H*C:/2;S#A\*jV8lh*F[M^^Og4OR2)dHEs7*sC^jnD&"p![k9a7gmH:thu,Q(lmHQi5ToK`J/k^rj<79^jUH!HCV53=N=r3>[N^8op,[XUF2O3:km:urk^ZhM!)@FbsU]gCh59$t6VP6paSK/_JqMr*i>$;!G_6[e#H9<858N4TTG$i"54Z,+IpThVn(GD6LitN)*3/[t@j'YRcTNB`HVr%Ot\TYpUEJE\(d0B[XU(7;:'Q&9&Fegn1HT>sqh!2LTf'TO==0oO*l?.hoL4>K[/,cY9PO_DU2-]_P_.;]kkM9J=bNE5=qIB6Sl0$(Rj8(:(25TR37qp5+h\"J!U7i*4C2.M]`Bj1ET_*=lUYZfb1K6G[+LjCh"p![k9a77.R2njI8C090LFNfpglBa048L'"61dGCJtf@X%a>Cc#_-cY^ZiAaD_$8Ui+ai2/_UY7pJen3rU,^**EfkX`pD+Da,&ZZ3Gn&1kKss(C-Q3Jq/HMcbImO`d>'C)pXP%Kg>Ijg5,_@$mq$-E-RL2:b2A[DB<c2PjO^q;Xb"p![k9a9P9p::c6*72.-Z$ET;PU;K'7ge"@ZN:<;h7!htme2=a.0!f6P!fm6H)=YU/eJ!7kT65$l`9P\n\5Zh=.+Nmm%D;mo3N-m]QQN7pe>3FpK*>9EM-GW`m]_fiXtH4V4QE>Au``ke)P^aJGCQMtU'A_gpkrf6V:!.sEe!!G`!+"Ze/?+0UCR.[3IJSsbjd1tPG'G/Z)l\j#Geb2T?.Lp^9-@YVjFS8!##/OG!!#PrV1&t=t@#I2Y*ihF>L2T[+"<`La#XD3#p-I5SlhRGWT%5:(R,"'X>JG=bS&=rIg@NM-_8Fk7$Vh21b.>dg4/R(Cc:Lg=Ie5o+JiJYm!97H]"u&%r\YW\l[aj26\nfk3NiaLR&:bo"+94^Tf`tNPp]F"-(ah#Wl(69V2Sp7p;`:=ss.h!(!,D"+YBq-3I+?rTes/lA2mjNQI1E)Xe$Z\#'Z;&4@TC+=M"%i+gnMp(-IRHmSZTYGZht>LBn=Nah70U643rAu``ke)P7(M*b\b[\ik-sN/m9A$E!=5D1!Y@aAK^j2FGCDFFX%V@9F[ts3l-Uc!NH`A&`b;u^,?77XGaq)B"<`N,BE0^remonlh&EsR\@n^L!Y@aA"<`La#ikg@\K_K2&V3RZ`_tg(]R\.m;_?B!!L[S!=5D1_Vf5?S%gq]?J_Z-7s[ElJfi>N/G)4.JiJYm!97H]#,9\Rg10t,^:''/L3Wdq+94_"5lcN#K.o:NYVeZf9X#d5%k)77DK8t/2JjhLX)uspb=o$t4pXCbB@-]e*3@7IF=QZ/e]5MtGKttn#5TGB61dGCK'7h@34m?TTFW*C3[-_[][i3GrI4$6m4mj^epopi5S.SU!+6eJaZpR*RI#dTMq'_l#:^4.]UnVR5p$4PLcu)0&:G,0pX6U=r:Rn3Xto8+$mFq3Q9Gmel+S?)d;%&6!=7[9)?>_K2&V3.:60I?l#(>WRo#2#C*+1YKK]Z,&:G+?+Sm4>n(u^1-^('')@*_fD3l+i//eMIK/FO&2G#ikoM9OFk\lQOKK]Z,&:G+?+Sm3STl[mPbN"bWb+)%g%m3l#H,?gS8Vni[s[uTCBQeoK`J/3/[t@j'YRcU-s<#])G:'Pn,@@-__+QO@1/n+G`2e7$p/B?Yj&M[bEi>EPUW-8.XeuI1UoOcCt?T3*;AqB[G,`TT=`Gl]4\7["(=G5S/>TJ0=\2&:)FeQg=BNkO+k(T#`S]+94]t70A'[YU0GaDqmOJ:lq>e<-O\sSo`4L&%-pO#XArL&:bo"+:sZqeK30e_UaiMl%t]r*s<^BZY0=m%b>/5Z?a*S[k$@e[]#\d2YBc;8.YARqB,c(oK`J/k^u,gRWZmC.c$q<^:"bnS",t4*blSb5lcKrMZs1A?qghL>IHCnrtO&DNah70res#H5S/>TJ0=\2&?37\]4,ej[/JU=WZq^1DkR[C&B%^.!#PrV1(_9*7p>S*n7^'Dl&!-)4pXCbI1UoOqB-?co%Wd/].in"V'fH*4pXCbB@-]e*3@7IUmcI%D_'P=D+f@ar+kt,UkedJqB,c(oK`J/k^u,'S"H<;;KYC^LrJGpFH(K9&+p+u5lcKrMZs1A?qghLP5:OAB['EQdoPps)(rhS#6>+)%g!;1*s+i//eMIK/FO&4C-uQS+8IO>L=Zq7u[7fS3@3D&:G+?+Sm2l%F/2(lP!]0=./\KT;)nu&B%^.!#PrV1([mVgiL)"GA3Pn/Ho?*k9d$\=rr)'J0=\2!($6C+<#L;Sc]]4[&mJI?>Q79:O8rCoK`J/3/[t@j'YRc,0]TRj8(.&[LBT8:ZN=?&:bo"+94_"5p/dYg.)e`'"$KJT!CLSO&L?QqB,c0S4*O1ENgblOlYg+X84f#p@_YRM#s@]!YAn:53SR3?L,/!0'R)pBihmLbdodY"&+b>#Xj`.rW`3H*]`bHG'Q:=q2,Cll>IPfOTuEtI1V2aBh`=<6s02f:3I]T+94]t70A'[YU0I7_<#\(.GLV73TD^]U\7RLTiOE#"TI)3c1gnUojoM&:bm%oYJGs%PNfo^<80NIORE#JG#ZJ!n#"aDtZL>N"YaRo+McN?!U`qNK*5;HZXAZlh*j\"`P4d!!^uXK,\YrV4_8u1*iJX,X$Rm2+NP7FnCni?@Q4P#[7L;7)kl17PN`q?@.$G;GtpL5O.P!3!P$YcCt?T3*;@F;fd:/M[_`n?R0XCBDo_9qhqEu_p-DD;hd/[PoXN?!lA_kJV5681iI_#)=CHrsE+NP7Fn79F.IsphZP1QP."0d7H5p0obNH]6^gSF5pN`7#*s+(T,0S6:9m*hk.YLj'n',*l+8#:u0Pi=54!/uOf))/7[_%qG=4C3MBHD7g@%g!;QME^7t\mIZ$o$TMmh!+"AmGlXD!E_S_+:ud)mM4s[EHud"_E7gH0S1#T(9SJZIEnEIlg]4qk=%5e//eMIK/FN#WQD]m/Ho@c:$&P+?$#Eol(Q[_4pXE8.Jf^[k-,l+4S7io8$t;nQQJ.?4Wlm(J.81_!mth5If=$*;/Ig"P8ht.hA+<:k_##?iGbkR%f3.(0p3Rqd>5,B!&+o6AAF/R^U$G1NbCMs9mN5F]V>lBqB,c(B'Fq3'6=i+S]0:5m%2FI5"Qus)D1?+)GY*OWGCfLpLap0FlZE^i]"(h2BC7d5I.XCQ=kUY:tA9J0=\2!($6C!3-FX$i37nel+eW*o<7c!]Z)B!97H]#0^Z02>c_!?Y`leD+33VS^Z"SXV6*oJ0=\2!($6C!%O&]o606Mg?("G0=K1BNmE?Q%nb"<`La#XArL&13Qe2toi*J+U.5Y<@++\Q?(DC$9JsSg^=lgW's7'"%VnT!CLSO#,;CPWfr#o(2HS)$eX=d\MXePi=54!/uOf)).3$c;K'1bdM+V:UD;;5lcN#K)c&%#@SD:e\(XtM+@3)Y9\SYe(.uqoM"tsk/D)i`j[JUp38aK6Z!*W&:bo"+94_"6'suY8+E<5kdrsKpitOaS+Le7j\57`I3Pb>=;TD(7j!\+GOOBo0b.O%G"m9V6nci[!PgZsP6"U$HCVT?GAA/;KS"ksqmc8p)athO+G`2e5S/>TTiTTEEo?56\GWjbAc"5d1=gXo0:dH(ffl?@p6P(Tm_D]Fj`0Z@hlgnki[s\qp11ZKkU7[2)I*'B8P)\Hj;,2\@q;AI4tG)1pGa%V5:#&Bk^`g9M%gtcd/5NR#i?ud>kPH!&+o6AAIu?H`QgZ0YJcj%g!;1*s<^B5)@QW]TK^?d7^\hXQao@oA1jXT((>:Ius>U&BugUk]9qqB,c(oK`J/k^uusN0]Dm:HZ3:bU)+:%.GQ#]f#eHSpEtD:0Z0I=a.1Lh,NuRo>^2U(7;:6luOSLd$V02%DJ>HR3(=K*m*8B[@+*:Fl%t"N4pXCbI1UoOqB/hG[;`1]bkI$c?hjMup9F;kI5&E!J$!5)cd/4t54bQ@5PeR+f:G)&gah']P]W4-m%1>7gZqo?G@Ykb*C,+8?9\D[d.EECKnrTNY>DtIb\PN4md8I.UKN"nrN^E>Q'.<)?GY`DSNhUQl0hYS=*m%Nl1#>l3/[t@j'YRcN+q'2^854q[O'^7]%cIh8e2YIoK`J/k^rjslIh1b112p=BOL1G^h^USI)1+J3IiK<<%D&V4FI2'86R;&:bo"+94_"6*+a(iO3mA^SoJGI;EiThPX;DtD>_[-Djha(gY=l^$dfdB'pjg0Ygn9M?EI*Zc4<;ZDfmlSLIVXm+T>]DdqT&Pa@?a&t5!(^Fh3?o4S3"r[r!&:bo"+94_"6*09RcS^24XPdF$C2UjLn%Cf/fAUn+HIVG/-KQ+pIise"4t]!(W%<8SBN]O^:i;R_(sN6h#5kmDU8"C@iShCmmfn#f@;4u+T"Wq0eQ#LFP*(mcql8`;QrT9/fF\8iEuQV_&'FaPb'L,Q@A/&J#L5(GZiU;t+5-`l0_o'AP3B7-:?qEp]i,@Rn3(ch:rI&rp>$=]t"FbS&eqCAC!&+o6AAC/krA!q66QZFRLcu)0&:G+jPi&)4Y8@32l`"6@-KUY^0>)\KH9lj$1"#^jK:6fWGX=]J[Ro7Q[`@WPDX]RKK@.ne!3#m0-p'NFpHG=L:S4ejX7LQulE=R0IQ]q9<&HQl#.X2cOcd#X[(s/DWiE'+e%LGYE>Au``ke)P(e8")U&NkXo%N]2-OMI%+94_"5lcN#K>:[D?2!`9o%g-^n=MN9=oBr:ksDGE^HoV14On[kK/Z=CKTFUH<$)bMrkIo00YjS"T(qYh63I/<\D;&i%7O@JhVK+iOiMO%aLdL^c5G[!@'RNqlKe]TXTYn8P^$1oK`J/k^u.]l'^hYDpSGhjmTVmD+eS6\_2IRc93OJa%_mk598a#Eq$YXoB&"nY):np!=_s*ibonP^g-df4i7[VR"Qq=?\(p.k9EQ2K.;5Dh\;pbTJN&T3Fa'*G=/G=V,)mq1I[Hs>>f^0CR(qJLsMLnMr5a]WC$<"0A-i*CpHFIYc%J8qe?"0rtC4k)\"[(`_MHD9o&%g!;1*sOY9#0c"iLC*MSh4iL.@/o?i#%uT7iG`PkEjKa&pC%5FI*eLnMA)n_uV+@"1DEWfBQ=70A'[YU0IXhQ;6.gY_dXL`0++ITQ?WFl2`p[3''daSk5tf5[BL+%4IU<`ZAu&%Z!ul+nNnZY0=m%b>/Ud-[O]qBpc9N-.Ze'NXil!.sEe5QlndO'?08<%j:[52J0R,sCh^k_!KsBBorp"*V'JW:+ICe:-?QQ+o?Q2ui]_s7o:m^Z<$)e`":>gCO>83c&C%*@8-/D@>q9PASQNpFQnN&'!\MBY2@NhDIf9qB1C8C:13%%VBrYip]F3etUmlI;djoteYf7d6lqK6&%*.?1mL1^T7l!q-AHrj6*f0@"=j1kBV?S(SsDjJ/k1YF""0+^n5Mmb;9drM_diWa=H@W!C8&uTRo$Te^Io]>^2KYj0a6Cp\Qql`)>G?Td(%0I&TIAn%,CXtATX(7RrkN'a\-SoZEk<0QdXkDEO18,h!`]:C%YGDaR'^"TAc^d/,b!)d'G>NTlLuBm'!/uOf)%][#=fh#Qi[f'_Etct8$(qe1&4@TCbjK`3n[8),-^1-dT)*O#6'H%(Bmi,6nYJHRDjJ/;I!.F9]3XC`.[[#$R?2nQdRi6\dJ0=\2@!8APV/,IN5L085o[KM.&"8i-f_WL,5Lb.\^7+uMeAg!,0+#k9RsSr7Q7iMYQc]Pu5Paq!lB_oQ%nI.jn-eJh95/%<[],51O'V^J:?dB:*J_O0;GuMX%&Tkt4a7OF8e.'E//eMIK/FMP^[p%JqR;']]BmrZae0.G&4@TCbcZ/iGHH;_@)YFrWBWlp1qWJ/p5G;rCH^;3giH,<2sX5i2q,OPUNjg$li*8k/*"^Y,Oq)pVbTFPrXf'Wg>@[8>!sj$f<`)2\$i\<^6uXV0;K+&e@N6&Y$Sf1PEU(MbaIPQjR_Q\(Ddn>e^Z[N:?g.KO0`L"0FlZE+Nu1p-=ZRV[7smtB8Q7K@d&FE"ORI*#iH(CX1t1:F&%&Bcd;tBS'0jg6.QR4r?3`X.[-GV*7R3(T#mRiTp@Dp5NJMk^sIcE-a2r038)dpiO[-`[TC\[/5S;u,@dl)+@bP+Yj,E+el5S/>TL^\1)qbZCJ^iAk9df8R_cn4KR^OSM(Lt"HAq..p;bPjAXc&7jY/s7g?B!-Bq6QZFRLpLap0FlZE+D_fVZ-taCQ(CM`I1UoOqB->@.N_W=j7G9rXV-!(p#ZglNMKeD"8PklE$[U:7,$l)b8>IJP&d6(C@GJ8qe?"(ANr%u,mVoK`J/k^u-R)7PZ>>I33:J*Y(T(Sh8;;PM['^a#rkiHK)c&%#6>+)%jE2+)%g!;1+%/N>C!6#_#G4b:g,nbg*\bH:Y[5\bS-]?*!T)1g4pXCbB@-]e*3@7I(gQL?XR4MHo4'01-DW"E"Ss5@61dGCK'7iC88bX.Q0_STccYUGb2C9pNK@.+94]t70A'[YU0IGk&VUIV736ncP8$(3_NNF`)QRU!(!,D"+YBq-3Kt8C$V\n[s=B]c:C`A'_FZkK'7ge"ORGdfrk'UJ%t.Em7#bc=u(hEL8;kH+s*q#B?qoe_LS'^nFhHtR4qV[g@Io*,DW@+"QcHbQ+%9Nk\U&i%h=He<_)4Vb,ac4]VAlUuodM!K+I1UoOqB,c(oYCu72m>NgreWfKC6s.Eh?i\*hKeS:bY[n=F4YZ'h.7,O^d'leNOJ(Y6E'nDU+=Rqf;,(4?tgVQBk_:DSdmFrcCt?T3*;Aqd.pVrTGhCEsKMm`n>&:bo"+94_"_%"P$b?6_5Wg?4O4PDsG^$,<9QT_/gj]=IWH8=_VrKY.pa`e8#4`GW+Y/Hk26KnB:pU.-erpp#GI[<7.-7;T\o[a>+MMe4-"3cP,BE0^remomk3lX5Q:YKV&jGASKd0B[XU(7;:6m(3Whd#u:^HST&/m'djYFOLAs'4i1!NuIdM+?I5r5O\:7dBCA62,?`Tmeq(qlj-$]C`qiZeOmHih.bO.nb1R^jJ,""p![k9a3rF@)YEK4*J.!Yk0nE6luOSLcu)0&:JMMIX^PE!IEYNlD*&j:Y[oAi0sMcMch]nb`2tc.3Q,u)H4!Pl->H_*j8*2/c+Co"F0lQ@:>l%FFa1+((C8`_3tdc_Ti(LqPS*mR!^#>OV,tG!=5D1!Y@aAgn;.!h[33VME3qNa1p-De5aLShrT2t^GkJQQln^hbY?%mlGjMZ>>lVC0$]BEU6"dkb7APX^Fco.fU7s!)`f?.^3jlY]?AM>hnZ+@O#j`3r="dfp8d/[PoXN<_H3S]Y^7GG4%=QZ_Zl%t@W4pXCbI1UoOqV]@&fEPZrnFE<\V`kT7HemuUSnUBp^,W&t?H@@WqT]+-;q+[?D,0+BE>*C,$@Qm,VP^5l\Vn/(WScpf&cCt?T3*;Aqi6&a&X6m[]/b(XB)];MHgBs$3Gge1DuK=WT<>d:WYb!*#3roK/r;?Klo)793oiUT9qB,c(S4*O1ENgbl_(oaIh3>B?gNHTPYE5?skidX!"ORGT#bhia<-;"0#5d>EJh>Dp!XP[X6asI_Wd6+3.&ILVOMY-W!#PrV1(YuYh=sOqE:(?Ef/rGdT#g-ZK)c&%#6>+)%u+',:s0(?i#AM28Da^raPO%J=50l)A1,_/!D)0#)?>_K2&Q[W#Eo\j:WR>lBU#+0"R(I<"ORGT#bhiae3i!.%JIV^X5B2V'0qXBS9h,N='nRL.C14^4pXCVB@-]e*3@7I:`QW!XR3IGs"?Nq3RA!3#bhh2&4@TK.te:)K#2sZKV?V11Ih!(7jQU&i%h=He:FWdIXB`PG8oRg,bqV'e#-qB,c(oK`J/k_!=5f%%7\&I4q"H"Phq/j29-.PoA6+8iTDs"2uo/Nj[G_jR!`.k)phaN-nE9$5B%/2&Z#]A5ccIEnPf^c5G[JW4Vo>hb1FCOP^4QB.ji1)'L,!($6C!.sEe@:4<4Ius>U29!C%:HWs*kF2&$5\LmU'P`%Mc4W&j2^(CUaIo>qB,c(oK`J/k_%jWF?=F7hb7oYId?9'YNEOpHHGLAf=YI0Y@)Te=F)eHgc@JWZEIhTI8IV!N020^.*^#]oK`J5.]gb-qBS!FCR_.E\qk90ogfTouO-Ge`8nGGOUI_poV0"UI[*,]TA_*aJ,Th[[I1UoO?QX)'B[cEblhftDk5ldMlh-i6It$'1R%'QaXRF#/1]S?tCGN+(moI3H1G(PG4pXCbI;lT!:;o,o%/2.\X@i@!Vafc!$%R;PDmXWV!VJ_nF4bK]6uErsM)&U(7;:jQhH)Za34^PP"=$j7.Gk7fu(kA7S+,ahN/pWl8GHX/f2!,.Ll1l(JMuC23$t:Hhnq#ICbCm<3i\^'//s!+6eJahV*9*kJJ'RO+TUY\s)!!.sEe?f8.=P9\48[rVMWdT3D*Q7:?E8m/$#8Wk`Nig57\b*>a0&:bn*&!#@6A+O3<^$XpabL3-d60:8uFLbWk'bsAK':5;^hUBpu4epu7IqUDc/NGgO.4H_Aq>"'l27U-A=Yp*q#AZ*!-L+s(rT3pa%FcKcr[9plM9uP/#`A=>k]/4BY-pBf-9"m9d$N;4gMaZtgoXr)C;W1BDakY"MgF!dD_%qnkfa=kIB:\#GeN'TYN"^?k?%i_+94]2+&r:?-au:e[^TJ![Q<2+qZW,%iGbTjV[*!!]r%YRko$6(B@-]e*3@8tRagu\BY110gmGqB60X5"N'7M4:t5/(otfM7kX)(\]Y)V7Jec@a:+a-1W:mZi4U-@>::Em$N@g+@e%3mVKDn?>\K0GHM56UXT!!nBE#XFKc&'d.I&df8Y:%$!/Mpg&T6/t&o/1E"S&-l3lM2A"&UXI%LK)YauX4>qVf\"m13B&n[mqS@]"+YBq-3L=3nToAEm*rI=VjgFS:LnA85lcM2U(9-9kaYQU\eXQ(Kt=aj(C:kL?:\?B9agL7]D-*R=II4Cn<1\L`e@JpXsk/&S6U;Se%>:cZnRrr#6>*6e-B"*HEQcu)Mgpsc^nER+7/@\PTJDWe0W)kTB_+<(RAC"ZY0=m%b>/uQ-NU[lT_l7m]oJ]A:!l6!.mo"#NreCjG;6495,eS/uPlr9BCT/n2?uLY:[CFO;A**S\qW&G:M/^V>AWo6LC&^Kb$QC]sNmAjACOn#XHbb4ubbk^\>lWD8Z7K\j$L]#6[n9B'(T?@!d74oWVq-=]lX#&(]i_/\4,Yd/[PoXNA7m9[$5Lk/V5kV:*Xel%pSbI1UoOPP%4s>oDl&]KP@(Y3eZ02i)I5:.56q8O`L74:ki(%/R+rNqUK%,p;'DOf(gF,kJ$)ufAXi+ct?#oj2rS2mqJ2/ZMBhi[K8n)Kk,S3/el\b&NSMZs1A?qgh,CE%->5D<#68*PjS#CR/6+J\QpoeG*mik-GAen;q+B&6J=ff.Zk*QL\eQkG\#)P8N+R7"h-V.$00fF-VpYYknce60U\<)0H(22>n!3;k@@n#8^7q>GIrR.g,t38Q@"*"&qo4U78P)M-+)H+D[^tF$p<&mo"+YBq-3Jhd0AisMNrF"r&(9K+'`&pP61TGCCd>O2\O$Cu33T\qs6N1+;dsf7OS\FZrMFTmCP>U;=JcKiOId2&6Q_8!/rqMJ++,C3($P`2q#^1S)l=)?>_K2&Q[mY-1UT)9([V4IkD&A\gbV"<^7:%o/-9I.1C_R6\aSasTgY%@SF=Ch?SU>JKZ[&`C^,i8h`_;29K5h@%U;k^ub#]:XJ;pD8OV^jU1A3>6=ff/CL4g+J))4'QginU]%^Jd=.mZ;?`X5@3;=X6$?:g.*PJ+Sm5;Xa^#l'9DG=i);"q.dAFFSdSD(`BNMV\@*2jf=&S0Ra6Y.491M'ZY0=m%b>/u=`a#AD+33d%FOu5kG9P`5kI>BJjS2s*Hn9[7B+L;iS>UqbEUs,g'orNHr.A%9dr7Hk_Dn2?1g6g1hZub%mnf/fY"+ucgtks^R4]ROfBYDZbt3)+^1u"#?@$".^eZJH5W4j%,6",+rb1B!+6eJahY1h3srBtd$B88Z`ZPt-`di,&:gF8oKeg2H!jWeEg5>irK`oM7--n#1=2kB4i.'ILiVd5q6>CFV./C@K)n;I8Xa[t8spNZ*Z+H7!0UC3#?CZEpM68XZa\88B/T`%O0`L"0FlZE?s.j"p1dg*im)#tE?Z'E&:gE]p-H/\H.]:L=d6R[r-"L`O-JsU$Wg91%"sZ;W.)6XBtC#QVQB8qQZoHp8RAJDL->l.cmnqNc2O3dVKc=m[d_KF+Q@AV%04DaRLC`iDsX(pe(>[YL\*usM*;21:iQ9h5dtd?7*.M&.)](j<4[!$]X/4UZ_7U3mpU!'$M]Lq1J1m=_E6a$]]atJ"Kes?HG#h$"^A@9ET9;oGfBgsff4lo!97H]#*qks:=D*eaUQA?j$YHp+94\Hl@WF3_Y].TAH^..($CjB-H3gU;a[#Mj#Zs23b"AG-t:R/!=2kA+*>,ZN^fineSf(Lb>?o2.'ljGR2X_F^5dnD(nbY'?/_\?4QqCt"KfBKp.Y-m>N;++(n8DYL@G732Keu_md?C4!W]HT4pW];dp&U7J9DXqi,;-Lcj'RW>&td-3L%/KIQu;`jG[QM;5QfM3AM-k*qs<>Qs$Zk>N=jp\Bke(j3gjq3;O`t6*'I,WdRtBBcH,rcB;#M"sDp)>;3\FmdYP%WiA8YjHHC8UlRb>\?N2VQ&?7T+Gf66^`l9aNb[=@Mes9,a,+@!5lcMfcj/`GPhXe*hLKT[8LZ^.O9N]$6U*48!cF@Q>,tbgQ`o7g"j5n^oS=*;4pXF#RegE+B;r=*hKNO/Ec9CbeVb`p+*Td*_!"`3'5sT?tDF3+TW0^@CO%_lr4pXF#WqtZG`5:7\k'aU13[m4eFPg!/SoDp^KM:cW!]=`rcn+lum_N:V>7A!W7H@mPMG5o?J$LleT]abuX5ER'#*&?aWnG4-`dNQ6OLQ@VqXREGjIOGar<7R80qNHWd6,ImCJ8BbN*,O`KRhG_:KFIF%U?)"&IcNho[d^I)1Gq/Y>iV;g-h[[p@#-@m=7=VlAntCY&HZJh!n%(TXYiRf/n>n,^jBS[r,'9%;[,-qP2?6Rdl07Gj(GC*Z#<=q14ff"o!!jgj#6A)C=88B6nYmA)?sSf0+WggeL4&-9e)ODujZOe:9-_[]iE[*FfTLlJ^J"!>IgM@rV=?a^"@U'a+06Kdh%[QE2&p]Z^J=cNE+T^Uc1Z/OEkV.pYtmUNH5M<>!s#Gf+)K@^$/O[U\U3^G561aBTVn^.:Hhld?-TrG!.ZbTHk8Qi4l%^^HD5GSbVh9CMA-Qr+94+V,p1VF;L,kJ=JYL29jon2#TI%#5Vp1\ia,t%`OBQW$WC^tLi#?NS)iJ.$jW=n>[s;RG@.^.^3Jt_Q+,Ta]r='g)]m8O7imNj[OBfUu_W-ZM!qbqT/uCWiB/;#QPE*O:MBuDD^/\\ifLWnZ9o253.=r^sI*caLqs"lgZuDW[?f<,O*6:>eFTGk#Id-1ds-WYD;VrJpB`7`L-`&C`kU^)D]e!H+!2+Slq]Dmm$321^a9Ma9bVjNA:6a,el-sm^Y"8AM%fu_uHp6324B5TPKjT,I4u0<>6NQF`=dKF;"*9ZR;!p1ebeH_tc"+;g/huuPbQl11@t+!bIsq*q?GB.8;b(Ch]m/k^BigcYGM-R$_Qpigg&L5k#m<"E:@\XVCUn#P&D`71f[D#6B>mRmkq.VG0+`Gl';C]H\qqRS1`+^q)o6RF0:MTm>d#0.C=94S:OM0.YU"L(^r\Mf+I-jt!/&[/ACaDl2Uh$$h?R`VmDY$L!EF_G6[AR_?09o!+(cCI&KY?t't9$58tR@V=V/H>deL3sP_1MrWtH90itr@dJo_?09o!!k'LSm';d43!\"\E/@RER[u]<\s%S!CDIm^k_/7Z!L?dS#X_(iB!!)MM%g$\kG!-PN3IuSi)?PG:&-.;M5=\GeP^1M2`GjslqKlRI?PD+saT]KV=j;>uSf?i=--V7DQH*OSmSlBTbm-70]CfppeXn,d`&V](T@3b=HF2DlR3+TkaNnn"^@pp%ID%.BcV2Y"ml\`O_nj*8;SCu!PR'nc_aLG^%N9N"<(`OE(>p;OiZWX9BR3(2RqEj3Y,ILuf68+_hK7\[-JH`Bp*ES:d540d?m$R)-oo8"CnWg^%E^sY@,kq":B@cIph7G,)T1/NAiX<"#YuV)nWWZJT*Mah5Hq"i!W*VZ)eba[`"Ad[s;N_+A<8?sQME5s>PS;UN`c=dk\IK);9Sd!#Q[boa*CWXh\$__re`]3"$)Ba5D00Sah],XJ(/MUIWjb&>H%L;SK!.]iTo0H,dH`GG@rhAN_d;&ga`PYe7J6:rVM[#r%N@,[(V?MAjs7CrLThU7qB==5'Eh\3s:="#qI(il(O:MDC,W`!>A.te`Bkl/j/EDE`'%.1b;3I8?$%$2De&9j1R+pW/NTh^&,sj"%Up%8mFtNp_HG"Lg7D2jMrN4ma0,lB6<[DelpBTm,K5-t2c`3eEV8(%H0`cs8NT>^1]gh3L7q>*Qbd.67#6;gV`hEJ8B!GJ09C!([MV[fh,CdK,T:O$b(F+s3NpGmHni3%aPr5MOcWn3_C?0C&*9$gOK4c9-2EMltH4?`^Pe?hK"n%bLp"ZR?XLcCH=2#/E-e8fj&[]Xkk/rJ&L#1($L$u9!-bLQD"9=Qua1NsAQk`?OFgPV-*9;_Pd^%W+]ok.SH+kOGVpMN"F`F#0/U5lKP-mN(^D(l#XaH5Z51L^:)=c^g(SA8;bjSGYGNR!bB.,I:)uiY'Dd7d$0AV(kdB^j>+,okUJ$9LU#6@r\83Y,j\Fa:)':n$Q05bV,TQh<3"pe=Gk$oRs>BppK\U>C&Iou+$jO7H_#@%IW,N1\Yl7%e/^&=VFc[E@!j@N:c[g(6R9nk?Gk/eL7CQm;>`O_T/Jp\+06t7rL6\1S&J8]`GX@s\T?="#6B@3%H`;^K>g/)qH1ioEGGXV`PYe75eroci8th#55G,WoUN4uk,uaibstRM$.57.Cd'!a4E145;L2b_?!Ch)Q\\7lq!fl4ZGAKC39NLE#=K;#ZsN4l]"!:qF.1"f.Y(<3[;2p-]PtZ-`1K?rA&XlUKr-WkK.?hhd14%iu7:qI_0BTq;CU%(*O1hBTjU0h=&!0FD\1h?[_$gInp$M'R*MG53QY9AO1eHGJ_GFQ?QdaZ?A/qA<\J:Zs)._Om=ZH\E'^*[$(0WaS8lQ'=mf<(?C;VY+eq;BiVXhK7%0AUnF3,N>Hk\f/2U4K$Bo7EU8JTRu:CI*HdVraOkh&l(<5&H[!>nJd!nU.QPU.+9MZsaK=829VO^)C3c`/@:oF*`UNqk/GmC0=Rm/M#(BA@_0i4$>JP;CmGe]7u\!GsTFXnJ`;TguF>B,B08AmJ9d5s>PS!Rd!*;3GS1OEm,F/'2[5j#)o/!jLXf;jXt4s)ACjH$1cl_q>cNkKu@A6`rR4HR-2%1@i829`7Igq;<@CrkQ*+TI&TigXW(L^J=d9lYgE?gpW]s2n?\D0[8^1ToJ5f>g'\Hd6=6k>sDXK@X.sHC`&'7&U9O8B/Z`kK"S+hYQMAFMe%MRTW`$-*=;@+WP:\>Qao10ak6'u:V2Tfk9Z7lVq;Ag+F0)DESm&%@k5$l?In*X)=feY+Ai?4kE`1XjKb!dPRS.,?"osO9Z"O,7*;_;DRIiM#O"VjFoKN:d:J*9fjc'e)PM^cV_SEC%mIuLQS`BXfOs`uLYH0#lF?P*PVh8@4bP+pYY_"bN_NWYbNlm1k\hec@IB4q+8&to9@!tMU-njF5:rnKirYA];3uR+3!:4uZ;h)[U?JPem#BXA[@-Diea?R>^ZRgdHXSt%sX1>M'PTB#c9X:#.h7*,ZkHB$:FNl[1+RB_J`!3?LHU&d[PKAZ)0d^O__H`EgKe^)?-5.rS=r1i/aFYCAVAmalXi;'3lc>WXG?G>pJp#^ln8iXTD2JI.C=ECpmHG,nm+TOfnf9uC0rR[U\rnRIRb6_+m_>3NeDUsLAJD;$>e<]).>P]Y,_bt[80Kg<+2)Q9]f)uNl%>e:=!3FN%N4CuNgfJ]-N(0SmC:1[XfkfF["1t#DA29Y!eus(C=Etpin_ooZ[9C9&_O9@((\^jsdSIP\nF$%RISVp5(ED!YC:V+=:1.@;=E?Zc\&N<.rU"hL:$O(nS:I6p&A!16HFP;8H?smifD2Z76_R`_T_3ja[/kg;M*;]g`f(!b@I?6+\t&9JU[pEuT^^THn\.aNX)>J6R.9F-ahR`=1$?[6*u5<_Z:aFe]))]PL;tWFgA@&]$\bR?s1J)jlcH7/!%bdJp=A\Nrn_p*ahP^()up!.Tr!#_5A_b;e4da"Y(nq"Ci=#/lf%n0*8OVk;n"E)]Zd8e:CS)rDcNcg>YJ4#QZM4M0\lEJcd*P3ctG7QD/CKJ.0jUqgA0r%Zm0dan(q"!j8S!7$MRW'Ws`\`*?G(HAU"nfNO[\Z;-@7:F?LXA+%g46o355c.#>CZb%jeg6m\32R$]#CF$@G]DpAIqNDl.&@!\7eQFhb-DeG5P'-5cT[.+;)PR1DQVE'[J=TLrFO$-Nk]T6NIBLW5(E/!eA;po7>&E13&p+aZ`C.-p$4;oD8@/M2E%Galch&(3o0#`.-nHP_"m.XKd!T,`\g%eGAO&+l"U1Hm6:n?ldD(s;9At@AJ9JCHI[NJ5k,U!->2sooQ8klc@7_B\9"@k)J!kE43kjo,5Ef]oIHV7L9m30![)WHt!%@!:\*FVP[hK?-0UO314A9U>,eH%g'h75t=`=HbSu*Y>T-rfiIo>WdK1jnq"hNDqf2HIkpT5oc.Q057$iSqLdCJm6A*@B!]UjqP$l\8>>H8kS.;E/Te.Z<(S%D+93f2`hEL0i*9jcPrCm3bK7VEKW?^[.UkSDe^3_H3TJMNU'+Wggemmal:mtPI?O;1JQp#L(JFVEhg!:i"u6]Y8)!r4cHh9W[:KmXF34>"iWj[1]M"tT-5IjHT*K)i2hZV\hT0l8Xa1C%3XN=^h4bQg9pIp4j<\++58J?iT7)B`EJ7Fh6'j3"PQgs'n0Dpc$LKJ-BW[>et/.o1%smrP)uQa]/$rH_shcggTc\RR5_qm$U1SJN/D:Utq&6#W]=lE\FkEi1qDIpC[nHL\AD;WlXBT8Hp^&mtHBGQ0)c`S^XefRphoY.bL3fjZY(WuB]9r8ib+r%I*Z`VO;!.*s;.=>Bs*arU8)/JgUXocWD!mZHc&$ID!)P/(70h5uC_M@d52-*W$Po%bbG!/"`&S,'7&[HLk7*'CLqm!WYII?a=/2^oj+KN;4\?":.0Hoq'DsBe#Md=dS74E8b6/OkJlac-;^="ah#G&"]a[j(G[Yd+f2u!Wg(adIY2pK9(_Hp>Ma`@U0`L'FA9s!#oIW=GOpDCR[ACar`#rV[]%!%0.LmBTjTeUpd0.*L8?n^0k4V+&pkH6%GnU1].m(Y,qr\R#H-m/o-m*DS8qpji9<0m$p!/kO_!jNn9Fe3(SqJ#NH`_r8`M*,T_K+M4P5u@N&^/me3cK>9[Ak!#,WId/X1;JMnC_"SX2!pLp2G@At?PB3c)l*s;jN%QcN^GV0V2S]u(@(duFM7:S-8ELc1FBW?[(Z=r_XlMct#5,Q?u<"gEq#T22k]9=-D:tmAII@1;'BIoX[Q*en(7Q<Xi(;3AMqr#LRubHW_=aBYgBlES*e[-]Z4Oia9R')H@9Y=ph&*HXK)F'J#(-j>:3X/me3fR>ul;Khq%lo#f0V"!.]Hi9nk@L1=7<<)/)c6,PV"&46q'r&+Y=C)t'EF$T+0c:Q;`N=T:.lL[e]sm0N:sBjmHQ=fYIj50.H1C4H3@ledd!r_q3?r@$VpB.D3j/UGu:!Pg1\a9O`iS`rJ=qdS)EEA%e%MLi$G!!)g[Rge8kA%2*(\KTN=!>"uK5^412Us*Ul_tXClUmkpq%e;pS$tD+o:AkKpqEUR8X^NN=>U#.I%X2gE)8Af,]lr@07KO93UdL=K)jk>a[*&j/!^3`aR:7k%e@GS36\bVamU2$I>R7H$7%U3I?U*1;o]d@NM5=CUs>m+3n!\a=\FbaNF%KSa[W-Vde1t/%B1GsE!l8-;u:9DHC&@k!t7>>0%5%tIn^1A=-6k$.X%$*=T:+3]IBk@nplAt*M/KLR%[rse6U]=teZm3E.siAYC+,O8dY@Ah+3c_dR#MeoEs\2prK+u@)Gi;Ic:BrUDe4pV.(;*SC%EgKSbENJq;^mC<=+"SE^'tW7WM[kYoI/m(DN(!gKa9UF0:qY20;5&aXOVsiKlW@3kh`s@2TR7-r<-ZT'\3;F3h_.Y-_CH^5ZB;k^T/tSFKc9]h3VeQj-bJ`(u],3eT2bkinLh"n)lcUjm;P!n/g0u-Y]ENRh$F>`bT_KhCEtlGL#X*'rP7_>$+i/TOaZXq93Un`>q1!s$=GePqF.8gWWMT;*kN#Das/q4Tdt=@4W;s5X*OAKAr`7V$Pn@bI*[>QNPEm%g"/LH@XlVb[\Q,Kk]+LI3U<66!Y!1Vc*DkFi7MWH`?ApjN$UCuRb^S#B1W:AuMj&]te2pS:[BaLWQtL3sOtagu4M(]#XTj7`hC%E7I^gm^?Q+2?TBj+4UL=HWOPR"NMVXX6ts_i3Q.q["3!UogH/YGh*sea-Picaij7)G]I$dCko8Are'Qg\Z[,kN4.HUWL+/VOeBQeu'#s2qtL8^erlo*JEcW?_41[m$]VU_B-&)M-:l0E!TbG8LNXq!^b0%F4tot&n,@sc"XPr'Rf>$JT\>o8J(!0@_uae3I:CG-&9PE`Kif&_E$jF[F>AC0kYICU\WR9++0Mfi0/-mp+d&(3ue28/B1+nFae]+HhR)4qkT;Du'/jOqL!PBf-tEE!A5FtN=Vm2<+`pCIk!rNT"E';3![u=C.-8IZKnF[@1ok%jQ?OLE%k\@^)r!4lp6S$%]L,%8V!DMrCDbNCdpNg=^Z@C&I%SD6a>hJVeduIP;W:2aiLof;E]:bCkhHj][=?XX4h9A-hotZ#&"9;6.R5sD+"lH"A%Tc3meGi&)nDiqq4Q<3'tWAZ0kZr=kXGO`MD,eT!$E@sP[5VR[(7(LBgaod>S\3E0srG-#k>(R-s&jIEA4/Tf"=Ha9K[&HIJ(N(!gKa9UF0:pB\Ph\WjL70>u[b5kD[#6>OU@HVk-o%7oc5+goLj_K\6K>qmLI7Ps1r5O>,dm,*/FNA="7p*31Vn!T.MkdjHIY5LuBKVbkWYF$eoWHMfO?5:OUn=FAA@H0NUXJt?"1&*AEcsHA_B6_ri5:]gB8s5qf,KVC@'c_U;tME?q;C5K_lTn;)QV\?Wr/+N'BXc7=)+o]KE\U`+Wi6S.MFl'LPsMI!5L@K70NotcS)d#"K?Ia*(geU1M*Qbo"g/KrgX"SB4KK_=ErelXJRUe=4$N)ONRm"*QIE)f2m]Z0AUnFDpbmD:f4`pp)=lfXSbp;.QGAX1f/uNjF_'63@ZCKk`Y\cT[O,GhnW>N!.]Hi9a7Fd<[A-EOfNV+=dK4s+%o+1,4c3Gi5eZ=DZnXg0d(0Z_g4/fteWNS"+hrQgsC2EqrTe;nPIpi>b5Mqt]8\&!C/1rT/mb4U5HIFcr"&d0@"jBQW5/p,2K>7r6q(BB+"N(!gKa9UF0:aEZj^,L8>cl$QK@/*]]O:MDG2Oft=d*Hj%^#TY@(0Hp2<\ho)9bZ$+K5)Y0`I^8)pdE54AR-gBb$cW620F=5[;Yp@m75R>/M_*Ef.'F*,:.fs4WH"KG<8_^6NrQmOQ$B_5Q]Kf%#LKj-Hi@RU;&'CIlnV!X:(;cM'4Ma@2NkX)I2r]a=B>epB3V1R;X-53[6)&b4X-if$7uamY#8l5Aua+PoS[hO.-8EH?^&el\TY&8bO\(2g_YSHq&oJ&e&7mTap29JI?eqtTEN*Q#E!A5F><=Vm2<+`p(.H4B1t^cUV0!$Q(%\NW>cliG^>!/5/`2"QB#%*uKn?!0@_uae1bA]m2pLjHHlF=I0+R+Wggm4kq!2g0D,GoV*U[gTM1,1`ou^2\9g&fR)E&I"\+L[M8)acp7"=RTj16]g+nASH%!>(&RP-a+o:)lV5a?"ARM=kokR1cDQch"7K#/eOOikXddSXYE8,)Z>g>EoUR5fKA^+eB[ncMqLr]Eq!suB<>-k5Fb)j^1ES.i/f2+^LPlWIiJmm]TsqKiHA.$*KQ)_Q0bt2u+M/jf=k;)m(1nd+Ic]/'[?aI36f`k"^ec`V5u(ZpSBsi'BM&QU9`a=VXf]i`39[15.>/Or"os_q[)nsdik"Nn\M&JLQ'IX1aTmTUL0,6?/W<+#!6>E!A5JHCZ!*jYZ1$((4L&H-q&hq9(i"760[IF-$X2+=M*TdBcoOri.9*e"94#MaEA3g)#R*J0VF_]97C@&[He.SXO`[lYr3PT20J)]D-bHg3I8(@1$MbeE0GNSqOR&8Ua?LeheZ20?Job9!frD*Ro90\[:/8)IE'ru8]2#9QBJ^))$f%@USq+(Z!!)g[Rg_p[H1g>eVrM+(4kA`1.Zb/BKRhH&\"WA_=IKWbm]&OPI*auCr<_?4H`Z+_N!:Qj-_'7Y_'H>1YqAo1(?bKBd]VuoJ4b^r+`GX/rBRX4:rA)CHjY0GY)t+:#sm5r%4HHr&sDPR_+U2?Y-E"H]hOER1o[M':G-M+k:(cu,`>]2Z,c1Oj+%6.<6/V$63;3Cm@CWXh7bU:!'skVbHFXQu'#cXuYb5G_OG_8uq_8#ouiJ*m&bkF[Z^mZ7,AR,=Mhh7Xe$Nq20"40<_@;LQNt't;o!dbP922Mapg=>=&(FP%*?*=h56!G"iudF?[qjQ5L2['[?G^_oIrMi61(%LNdkfD$2B7Z9b335u<=8fm]Wu&X=AJYQhM5"+`a%#U!';'5']S'pf5Ho"k,[09`a-pL)8haO*C1eOFger0@l!_&L^7e7*l)@XNR9^V>8!D-4!PI9pB/I-"N,rV:rUpW)IVT6ic\q:F&%WueBLotn_Pfk`PQMWV<@[B0iBSO_@PRCN!sg9&-)qtlX;qScON=>2=_HS='@1X-r]5Ocjl?Z(kZ]&WLE;rsonG,]r$ri(&*S=Z8G`qYLqN1T:*!3T_:)L(B2p@rJG.mA]d=%Se?K:M_6GbNm;4]-iZG5r0jf7q5``WhM8?G(N&I!coDd/X/eJ:%omiJX-XKT8O@E!>QJ?ql^n!m&[H4QXO8O+_SpBG^!'Up?\AE2nL.hY'WFRL[`89'e;6,VW0C:E/qN!nZf`FWS:7.T%][GpL*tdm]ESIU)1*""8N27L4e:O0DGK8`#E\Kf3\/nY7F8i'_br)1c./`6K/@oO8TT'J'a\(r13[s[e"8<(\AM[MqdCjp$cRMl7t%^4)Q@U,QfYRaj%lTn$-,33F2:]'W`=50.pQ!"ge7/]GcN#Fl\q?cdL64rcS!#[LC;LY@p!;M)]Vo#Pt+'c,#GW+*SfY4K\";s\)X;!oTqL30>[UQep#TC3AFqa3$A;4k4?!GD*lX\uKc2!_'s5GP&3KSa+f>ZI@g:he,H[gP8mp5NNmW]oF"gJj(I-JpL^']3FmTED)+]paIcLhbC?8>JB;!dB(O"Q@qV[T?N\GaEj?YOZ_D#YEWF1;+NT8-?#=!WW5b+nMHCm\f:ff#>a)=05[g3BKNMqm1:gqFXY/gtq#U8q4GHVsf$/SX'#DLeB]KbWk4FjtE6;j^B^o"j>"@9bY7oCCn)<'f\0pO9J=p><%pl?O.!4=H7]V,J!!%hLUSD=Oq-\W!4mN=R!!((F5m`]R8O=43NuQ*#6AAOC8$ePDM-e_:KZ3_$u=)mm3W`Fo!/[6pD7A.%8*GsdPFTpkID<@$/_D9ccA5n\i;]a/RNBFB@O:B]e+E.Y9_*=d[H1Ba-N)E^@K+]X@Q2</)UJ0I+K#rM8<]!VeS2.BsZMg#-^@M1?YN(*nUhW6TCC>e+LA>4HGD=Ii:Mrl:IK9Ek$C+5k_0iJe$D.k0!+6?5+=P$^dM"q`UM[;rFZH[,a9MauZeMGNY$3m<.e:*SCoo-.S8"e=K.H(a*MAgT@$M$07+s,*G\>"hRSbo-71L:&iaegpjI=_@\:`q@rf[&GVlkKpCQd[mI8QP(a8N-DI.3E5YD?1mH:Rg4p\==V1#J%/L_]7P!it/"Q+c`9nFNi4Xu#BKcQ$()Grattf5nC=Cq8AJ>P+)1ak%Suc6I#gMu)*cO?6@p;qH8b3RcdT2kThjOWCoTE5-0[*sDcV_gJ\2Z;p,$IpD\en-]3r;06r'D(m&JBL%mk!.[5nK+R)fU6K!dp/#f'bHL0HXinsFPc_6Z`tBV"=!#^Q\$48HF?fU!$]b\!OI">:DpRu`Z6O*,HJd9+IKR-g.q2cZ_IJ*QD#pXD(H,\;&@k17XT?PiLjXe5F3#EKQ>&[>6A:49G7jGqtVIf@mD5C36)cV0CU(O%`TRQk,)HrKYaf6GeTB/-a;r7fu@oborX%@9Xc=:fG4lPEq,_X$6.,NFcVQ,.TpIMi)P?hn*eloJIf15>TIpKSFomp&ASlcoaM"p!;ciSi`)k(=Zp*NU=c!!",`KTO5sBoA@=J>#+9JGnNsZM6<^Uq,EQ+Q!s7?9PR\XbZh4!2_mcV2HHh*T.I'@E8KTe0n6(?>eF2.=\7^#tj[l9dpqF[r?RIn\\="C#fOijS3TD!M+gM^mMf8U$AN0LabY