Refactor: remove legacy CLI pipeline, simplify to n8n-driven service

This commit is contained in:
erdemerikci 2026-04-22 18:09:22 +03:00
parent 45d80dfaa6
commit 5d8c08dc86
29 changed files with 179 additions and 144705 deletions

View File

@ -1,145 +0,0 @@
# 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

646
README.md
View File

@ -1,646 +0,0 @@
# 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: AB=1.5 km, BC=1.5 km, AC=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

View File

@ -1,181 +0,0 @@
# 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
```

View File

@ -1,463 +0,0 @@
#!/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()

84
main.py
View File

@ -1,84 +0,0 @@
import sys
import argparse
import logging
from typing import Any
from src.api.data_fetcher import APIDataFetcher
import batch_generate
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler('lightning_report.log')
]
)
logger = logging.getLogger(__name__)
def main():
"""Generate DOCX reports (from wind_farms_config.json)."""
try:
parser = argparse.ArgumentParser(description="DOCX lightning report generation (from wind_farms_config.json)")
parser.add_argument("--config", default="wind_farms_config.json", help="Path to wind_farms_config.json")
parser.add_argument("--farm-id", default=None, help="farm_id to process (if omitted, process enabled farms)")
parser.add_argument("--force", action="store_true", help="Process even if farm is disabled")
args = parser.parse_args()
logger.info("Starting DOCX report generation...")
config = batch_generate.load_wind_farms_config(args.config)
farms = config.get("wind_farms", [])
api_cfg = config["api_config"]
api_fetcher = APIDataFetcher(
base_url=api_cfg["base_url"],
timeout=api_cfg.get("timeout_seconds", 30),
retry_attempts=api_cfg.get("retry_attempts", 3),
)
if args.farm_id:
farms_to_process = [f for f in farms if f.get("farm_id") == args.farm_id]
if not farms_to_process:
logger.error(f"Farm '{args.farm_id}' not found in {args.config}")
sys.exit(1)
else:
farms_to_process = farms
results: list[dict[str, Any]] = []
for idx, farm in enumerate(farms_to_process, 1):
farm_id = farm.get("farm_id")
name = farm.get("name", farm_id)
if not args.force and not farm.get("enabled", True):
logger.info(f"[{idx}/{len(farms_to_process)}] Skipping disabled farm: {farm_id} ({name})")
continue
logger.info(f"[{idx}/{len(farms_to_process)}] Processing farm: {farm_id} ({name})")
result = batch_generate.process_farm(farm, api_fetcher, config)
results.append(result)
if result.get("status") != "success":
logger.error(f"Report generation failed for {farm_id}: {result.get('error')}")
# Keep going if doing batch; stop early for single farm.
if args.farm_id:
sys.exit(1)
for r in results:
if r.get("status") == "success":
logger.info(f"✅ DOCX report saved as {r.get('docx_path')}")
logger.info("Lightning report generation completed successfully!")
except FileNotFoundError as e:
logger.error(f"File not found: {e}")
sys.exit(1)
except ValueError as e:
logger.error(f"Data validation error: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"Unexpected error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()

Binary file not shown.

View File

@ -1,97 +0,0 @@
# 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="<slug>_<start>_<end>_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`.

View File

@ -143,12 +143,19 @@ def _epoch_ms_to_local_str(epoch_ms: Any, timezone_name: str | None) -> str | No
return None return None
def _coerce_float(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def apply_farm_config(payload: dict[str, Any]) -> None: def apply_farm_config(payload: dict[str, Any]) -> None:
""" """
Mutate the global `src.config.config` singleton per request. Mutate the global `src.config.config` singleton per request so the rest of
the reporting pipeline reads farm-specific values from the n8n payload.
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 {} 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] ordered_rings = [int(rings_obj[k]) for k in ("r1", "r2", "r3", "r4", "r5") if k in rings_obj]
@ -162,6 +169,21 @@ def apply_farm_config(payload: dict[str, Any]) -> None:
config.wind_farm_name = payload.get("customer_name") or config.wind_farm_name or "Wind Farm" config.wind_farm_name = payload.get("customer_name") or config.wind_farm_name or "Wind Farm"
config.timezone = payload.get("timezone") or config.timezone config.timezone = payload.get("timezone") or config.timezone
# Trust the farm-wide centroid provided by the n8n workflow instead of
# recomputing it from turbine coordinates downstream.
centroid_lat = _coerce_float(payload.get("centroid_lat"))
centroid_lon = _coerce_float(
payload.get("centroid_lon")
if payload.get("centroid_lon") is not None
else payload.get("centroid_lng")
)
config.centroid_lat = centroid_lat
config.centroid_lon = centroid_lon
# n8n-supplied monitoring boundary is the authoritative outer analysis radius.
boundary_m = _coerce_float(payload.get("boundary_m"))
config.analysis_boundary_m = int(boundary_m) if boundary_m is not None else None
start_label = _epoch_ms_to_local_str(payload.get("t_start"), 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) end_label = _epoch_ms_to_local_str(payload.get("t_end"), config.timezone)
if start_label: if start_label:

View File

@ -21,6 +21,9 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
# Shared secret the n8n HTTP Request node must send as `X-Report-Token`.
# The service refuses all /generate calls if this is unset.
- REPORT_SERVICE_TOKEN=${REPORT_SERVICE_TOKEN:-}
# Only needed if n8n does NOT pre-compute gemini_text and you want the # Only needed if n8n does NOT pre-compute gemini_text and you want the
# service to call Gemini itself. # service to call Gemini itself.
- GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_API_KEY=${GEMINI_API_KEY:-}

View File

@ -10,13 +10,14 @@ Run locally:
""" """
from __future__ import annotations from __future__ import annotations
import hmac
import logging import logging
import os import os
import tempfile import tempfile
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any from typing import Any
from fastapi import FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from src.reporting import docx as docx_module from src.reporting import docx as docx_module
@ -36,6 +37,21 @@ app = FastAPI(title="Lightning Report Service", version="1.0.0")
DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
def require_report_token(x_report_token: str | None = Header(default=None)) -> None:
"""
Shared-secret gate for the public `/generate` endpoint.
Fails closed: if `REPORT_SERVICE_TOKEN` is unset in the environment the
service refuses every request rather than silently serving open traffic.
"""
expected = os.getenv("REPORT_SERVICE_TOKEN")
if not expected:
logger.error("REPORT_SERVICE_TOKEN is not configured; refusing request")
raise HTTPException(status_code=503, detail="Service is not configured")
if not x_report_token or not hmac.compare_digest(x_report_token, expected):
raise HTTPException(status_code=401, detail="Invalid or missing X-Report-Token")
@contextmanager @contextmanager
def _override_gemini_commentary(override_text: str | None): def _override_gemini_commentary(override_text: str | None):
""" """
@ -76,7 +92,7 @@ def health() -> JSONResponse:
return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version}) return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version})
@app.post("/generate") @app.post("/generate", dependencies=[Depends(require_report_token)])
async def generate(request: Request) -> Response: async def generate(request: Request) -> Response:
try: try:
payload: dict[str, Any] = await request.json() payload: dict[str, Any] = await request.json()

View File

@ -2,7 +2,6 @@ pandas>=1.5.0
numpy>=1.21.0 numpy>=1.21.0
plotly>=5.15.0 plotly>=5.15.0
kaleido>=0.2.1 kaleido>=0.2.1
scikit-learn>=1.3.0
requests>=2.31.0 requests>=2.31.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
python-docx>=1.1.2 python-docx>=1.1.2

View File

@ -1,100 +0,0 @@
#!/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 <json_file_path> [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()

View File

@ -1,93 +0,0 @@
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)
}

View File

@ -1,275 +0,0 @@
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

View File

@ -7,20 +7,22 @@ import os
class Config: class Config:
""" """
Centralized configuration for global/default settings. Centralized configuration for global/default settings.
IMPORTANT: Farm-specific settings (distance_rings, ring_colors, wind_farm_name, Farm-specific values (distance_rings, ring_colors, wind_farm_name, centroid,
file paths, date ranges) are managed in wind_farms_config.json and set dynamically date range, timezone) are populated per request by
by batch_generate.py. The values below are only fallback defaults for backward `report_service.adapter.apply_farm_config()` from the n8n payload. The
compatibility and should NOT be configured here. defaults below are only fallbacks for direct callers that bypass the service.
""" """
# Farm-specific settings (set by batch processing, DO NOT configure here)
distance_rings: List[int] = None distance_rings: List[int] = None
ring_colors: List[str] = None ring_colors: List[str] = None
wind_farm_name: str = None wind_farm_name: str = None
analysis_start_date: Optional[str] = None analysis_start_date: Optional[str] = None
analysis_end_date: Optional[str] = None analysis_end_date: Optional[str] = None
timezone: Optional[str] = None timezone: Optional[str] = None
centroid_lat: Optional[float] = None
centroid_lon: Optional[float] = None
analysis_boundary_m: Optional[int] = None
# Lightning data source configuration # Lightning data source configuration
# By default, lightning data is expected from API (JSON output). # By default, lightning data is expected from API (JSON output).
@ -35,9 +37,6 @@ class Config:
# Histogram parameters (global defaults) # Histogram parameters (global defaults)
histogram_params: Dict[str, Any] = None 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) # Centralized histogram layout configuration (global defaults)
histogram_layout: Dict[str, Any] = None histogram_layout: Dict[str, Any] = None
@ -80,13 +79,7 @@ class Config:
'image_scale': 2, 'image_scale': 2,
'image_engine': 'kaleido' '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 # Global configuration instance
config = Config() config = Config()

View File

@ -1,205 +0,0 @@
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

View File

@ -15,15 +15,14 @@ from docx.oxml import OxmlElement
from docx.oxml.ns import qn from docx.oxml.ns import qn
from docx.shared import Cm, Inches, Pt, RGBColor 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.histogram import create_lightning_histogram_pages
from src.analysis.risk import calculate_turbine_risks from src.analysis.risk import calculate_turbine_risks
from src.analysis.statistics import calculate_lightning_statistics from src.analysis.statistics import calculate_lightning_statistics
from src.config import config from src.config import config
from src.reporting.precompute import precompute_group_distances_and_rings from src.reporting.precompute import precompute_distances_and_rings
from src.reporting.docx_sections import ( from src.reporting.docx_sections import (
_build_current_vs_distance_chart, _build_current_vs_distance_chart,
build_group_lightning_table_data, build_lightning_event_table_data,
build_risk_table_data, build_risk_table_data,
) )
from src.utils import ( from src.utils import (
@ -461,8 +460,17 @@ def create_docx_report(
_add_paragraph(doc, period_label, size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER, bold=True) _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) _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 # Prefer the farm-wide centroid supplied by the n8n payload (via
centroid_lng = float(turbine_df["lng"].mean()) if len(turbine_df) > 0 else 0.0 # `report_service.adapter.apply_farm_config`). Fall back to the turbine
# mean only when the service did not pre-configure it (e.g. direct callers).
if config.centroid_lat is not None:
centroid_lat = float(config.centroid_lat)
else:
centroid_lat = float(turbine_df["lat"].mean()) if len(turbine_df) > 0 else 0.0
if config.centroid_lon is not None:
centroid_lng = float(config.centroid_lon)
else:
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, "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"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"Longitude: {centroid_lng:.5f}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER)
@ -499,9 +507,7 @@ def create_docx_report(
storm_data = filter_storm_data_by_date_range(storm_data, start_date, end_date) 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) 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) 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) 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) histogram_figs = create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng)
@ -669,67 +675,66 @@ def create_docx_report(
doc.add_page_break() doc.add_page_break()
# Group maps + charts + risk tables # Farm-wide maps + charts + risk table, anchored at the n8n-supplied centroid.
for group_info in group_data["groups"]: centroid_row = pd.Series({
centroid_row = pd.Series({ "lat": centroid_lat,
"lat": group_info["centroid_lat"], "lng": centroid_lng,
"lng": group_info["centroid_lng"], "name": config.wind_farm_name or "Wind Farm",
"name": f"Group {group_info['group_id'] + 1}", })
}) pre = precompute_distances_and_rings(centroid_lat, centroid_lng, lightning_df, config.distance_rings)
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) _add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14)
if start_date and end_date: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) _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) 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) _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)
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() 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 # Daily breakdown
_add_title(doc, "Lightning Breakdown by Distance Rings", size_pt=14) _add_title(doc, "Lightning Breakdown by Distance Rings", size_pt=14)
if start_date and end_date: if start_date and end_date:
@ -897,38 +902,37 @@ def create_docx_report(
) )
doc.add_page_break() doc.add_page_break()
# Lightning event tables per group # Farm-wide lightning event table, anchored at the n8n-supplied centroid.
for group_info in group_data["groups"]: _add_title(doc, "Detailed Lightning Event Data", size_pt=14)
_add_title(doc, "Detailed Lightning Event Data", size_pt=14) table_data, row_colors = build_lightning_event_table_data(centroid_lat, centroid_lng, lightning_df)
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]:
if table_data and table_data[0]: table_data[0] = [
table_data[0] = [ str(h)
str(h) .replace("Time (Local)", "Time\u00A0(Local)")
.replace("Time (Local)", "Time\u00A0(Local)") .replace("Current (amps)", "Current\u00A0(amps)")
.replace("Current (amps)", "Current\u00A0(amps)") .replace("Height (m)", "Height\u00A0(m)")
.replace("Height (m)", "Height\u00A0(m)") .replace("Lightning Type", "Lightning\u00A0Type")
.replace("Lightning Type", "Lightning\u00A0Type") .replace("Proximity (km)", "Proximity\u00A0(km)")
.replace("Proximity (km)", "Proximity\u00A0(km)") for h in table_data[0]
for h in table_data[0] ]
] available_width_cm = float(content_width) * 2.54
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]
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(
font_pt, col_widths_cm = _fit_one_line_table_layout( table_data=table_data,
table_data=table_data, available_width_cm=available_width_cm,
available_width_cm=available_width_cm, min_col_widths_cm=min_col_widths_cm,
min_col_widths_cm=min_col_widths_cm, max_font_pt=15,
max_font_pt=15, min_font_pt=8,
min_font_pt=8, )
) _add_table(
_add_table( doc,
doc, table_data,
table_data, row_colors=row_colors,
row_colors=row_colors, column_widths_cm=col_widths_cm,
column_widths_cm=col_widths_cm, font_size_pt=float(font_pt),
font_size_pt=float(font_pt), autofit=False,
autofit=False, )
) doc.add_page_break()
doc.add_page_break()
# Appendix # Appendix
_add_title(doc, "Appendix", size_pt=16) _add_title(doc, "Appendix", size_pt=16)
@ -1012,59 +1016,16 @@ def create_docx_report(
_add_bullets( _add_bullets(
doc, doc,
[ [
"Turbines are grouped by proximity (within 4km of each other)", "Farm centroid is provided by the monitoring workflow and shared by every map and table in this report",
"Group centroid = Average latitude and longitude of all turbines in the group", "Distance rings are drawn from the farm centroid; innermost rings represent the highest proximity risk",
"Distance rings are drawn from the group centroid, not individual turbines", "Lightning proximity for each strike is measured from the farm centroid using the Haversine formula",
"Single isolated turbines get their own centroid (individual analysis)", "The monitoring boundary defines the outer analysis radius — strikes beyond it are excluded",
"Distance calculations use Haversine formula for accurate geographic distances",
], ],
size_pt=10, size_pt=10,
) )
# 5. Turbine Grouping Method # 5. Frequent Lightning Activity Period Detection Algorithm
_add_title(doc, "5. Turbine Grouping Method", size_pt=14) _add_title(doc, "5. Frequent Lightning Activity Period Detection Algorithm", 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, "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, "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_paragraph(doc, "Step-by-Step Process:", size_pt=11, bold=True)
@ -1081,8 +1042,8 @@ def create_docx_report(
size_pt=10, size_pt=10,
) )
# 7. EarthNetworks Total Lightning Network (ENTLN) # 6. EarthNetworks Total Lightning Network (ENTLN)
_add_title(doc, "7. EarthNetworks Total Lightning Network (ENTLN)", size_pt=14) _add_title(doc, "6. EarthNetworks Total Lightning Network (ENTLN)", size_pt=14)
_entln_blocks = [ _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.", "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.", "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.",

View File

@ -1,13 +1,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
from src.config import config from src.config import config
from src.reporting.precompute import precompute_group_distances_and_rings from src.reporting.precompute import precompute_distances_and_rings
def _build_current_vs_distance_chart( def _build_current_vs_distance_chart(
@ -125,10 +123,10 @@ def _build_current_vs_distance_chart(
return fig return fig
def build_group_lightning_table_data( def build_lightning_event_table_data(
centroid_lat: float, centroid_lng: float, lightning_df: pd.DataFrame centroid_lat: float, centroid_lng: float, lightning_df: pd.DataFrame
) -> tuple[list[list[str]], list[str]]: ) -> tuple[list[list[str]], list[str]]:
pre = precompute_group_distances_and_rings( pre = precompute_distances_and_rings(
centroid_lat, centroid_lng, lightning_df, config.distance_rings centroid_lat, centroid_lng, lightning_df, config.distance_rings
) )
rows: list[list[str]] = [] rows: list[list[str]] = []
@ -202,18 +200,17 @@ def build_group_lightning_table_data(
def build_risk_table_data( def build_risk_table_data(
turbine_df: pd.DataFrame, group_info: dict[str, Any] turbine_df: pd.DataFrame,
) -> tuple[list[list[str]] | None, list[str] | None]: ) -> tuple[list[list[str]] | None, list[str] | None]:
if "risk_log" not in turbine_df.columns: if "risk_log" not in turbine_df.columns:
return None, None return None, None
group_turbines = turbine_df.iloc[group_info["turbine_indices"]]
rows: list[list[str]] = [] rows: list[list[str]] = []
row_colors: list[str] = [] row_colors: list[str] = []
from src.utils import get_risk_definition_by_fixed_intervals, get_turbine_color_by_fixed_intervals from src.utils import get_risk_definition_by_fixed_intervals, get_turbine_color_by_fixed_intervals
for _, turbine in group_turbines.iterrows(): for _, turbine in turbine_df.iterrows():
risk_log = float(turbine.get("risk_log", 0) or 0) risk_log = float(turbine.get("risk_log", 0) or 0)
color = get_turbine_color_by_fixed_intervals(risk_log) color = get_turbine_color_by_fixed_intervals(risk_log)
rows.append( rows.append(

View File

@ -1,63 +1,10 @@
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from typing import List, Tuple, Optional, Dict, Any from typing import List, Dict, Any
from src.analysis.geospatial import haversine_distance_vectorized 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( def precompute_distances_and_rings(
centroid_lat: float, centroid_lat: float,
centroid_lng: float, centroid_lng: float,
lightning_df: pd.DataFrame, lightning_df: pd.DataFrame,

View File

@ -128,26 +128,13 @@ def format_period_display_for_report(start_value: Optional[str], end_value: Opti
end_display = _format_one(end_value) if end_value else "" end_display = _format_one(end_value) if end_value else ""
return start_display, end_display 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: def get_analysis_radius_m() -> int:
from .config import config from .config import config
boundary = config.analysis_boundary_m
if isinstance(boundary, (int, float)) and boundary > 0:
return int(boundary)
rings = config.distance_rings or [] rings = config.distance_rings or []
grouping = config.grouping_params or {} return int(max(rings)) if rings else 0
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: def get_turbine_color_by_fixed_intervals(risk_log_value: float) -> str:
""" """

View File

@ -1,77 +0,0 @@
[
{
"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
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@
pyproj>=3.0.0
pandas>=1.3.0

View File

@ -1,262 +0,0 @@
#!/usr/bin/env python3
"""
UTM ED50 to WGS84 Coordinate Converter
This script converts UTM coordinates in 6-degree zones from ED50 (European Datum 1950)
reference system to WGS84 format.
Requirements:
pip install pyproj pandas
Usage:
python utm_ed50_to_wgs84_converter.py input_file.csv output_file.csv
python utm_ed50_to_wgs84_converter.py --interactive
"""
import argparse
import pandas as pd
import pyproj
from typing import Tuple, List, Optional
import sys
class UTMED50ToWGS84Converter:
"""Converter for UTM ED50 coordinates to WGS84."""
def __init__(self):
"""Initialize the converter with ED50 and WGS84 projections."""
self.ed50_utm_projections = {}
self.wgs84_utm_projections = {}
def get_ed50_utm_projection(self, zone: int, northern: bool = True) -> pyproj.Proj:
"""Get ED50 UTM projection for a specific zone."""
key = (zone, northern)
if key not in self.ed50_utm_projections:
hemisphere = 'N' if northern else 'S'
proj_string = f"+proj=utm +zone={zone} +ellps=intl +towgs84=-87,-98,-121,0,0,0,0 +units=m +no_defs"
self.ed50_utm_projections[key] = pyproj.Proj(proj_string)
return self.ed50_utm_projections[key]
def get_wgs84_utm_projection(self, zone: int, northern: bool = True) -> pyproj.Proj:
"""Get WGS84 UTM projection for a specific zone."""
key = (zone, northern)
if key not in self.wgs84_utm_projections:
hemisphere = 'N' if northern else 'S'
proj_string = f"+proj=utm +zone={zone} +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
self.wgs84_utm_projections[key] = pyproj.Proj(proj_string)
return self.wgs84_utm_projections[key]
def convert_single_point(self, easting: float, northing: float, zone: int,
northern: bool = True) -> Tuple[float, float]:
"""
Convert a single UTM ED50 point to WGS84 lat/lon.
Args:
easting: UTM easting coordinate in meters
northing: UTM northing coordinate in meters
zone: UTM zone number (1-60)
northern: True if in northern hemisphere, False if southern
Returns:
Tuple of (latitude, longitude) in WGS84 decimal degrees
"""
if not (1 <= zone <= 60):
raise ValueError(f"Invalid UTM zone: {zone}. Must be between 1 and 60.")
# Create ED50 UTM projection
ed50_proj = self.get_ed50_utm_projection(zone, northern)
# Create WGS84 lat/lon projection
wgs84_latlon = pyproj.Proj('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs')
# Create transformer from ED50 UTM to WGS84 lat/lon
transformer = pyproj.Transformer.from_proj(ed50_proj, wgs84_latlon)
# Transform coordinates directly to WGS84 lat/lon
wgs84_lon, wgs84_lat = transformer.transform(easting, northing)
return wgs84_lat, wgs84_lon
def convert_dataframe(self, df: pd.DataFrame, easting_col: str, northing_col: str,
zone_col: str, northern_col: Optional[str] = None,
northern_default: bool = True) -> pd.DataFrame:
"""
Convert UTM ED50 coordinates in a DataFrame to WGS84.
Args:
df: Input DataFrame with UTM coordinates
easting_col: Column name for easting coordinates
northing_col: Column name for northing coordinates
zone_col: Column name for UTM zone
northern_col: Column name for northern hemisphere flag (optional)
northern_default: Default value for northern hemisphere if column not provided
Returns:
DataFrame with additional WGS84 lat/lon columns
"""
result_df = df.copy()
result_df['wgs84_lat'] = None
result_df['wgs84_lon'] = None
for idx, row in df.iterrows():
try:
easting = float(row[easting_col])
northing = float(row[northing_col])
zone = int(row[zone_col])
if northern_col and northern_col in row:
northern = bool(row[northern_col])
else:
northern = northern_default
lat, lon = self.convert_single_point(easting, northing, zone, northern)
result_df.at[idx, 'wgs84_lat'] = lat
result_df.at[idx, 'wgs84_lon'] = lon
except (ValueError, KeyError) as e:
print(f"Warning: Could not convert row {idx}: {e}")
result_df.at[idx, 'wgs84_lat'] = None
result_df.at[idx, 'wgs84_lon'] = None
return result_df
def interactive_mode():
"""Run the converter in interactive mode."""
converter = UTMED50ToWGS84Converter()
print("UTM ED50 to WGS84 Coordinate Converter")
print("=" * 40)
print("Enter coordinates (type 'quit' to exit)")
print()
while True:
try:
# Get input
easting_input = input("Enter easting (meters): ").strip()
if easting_input.lower() == 'quit':
break
northing_input = input("Enter northing (meters): ").strip()
if northing_input.lower() == 'quit':
break
zone_input = input("Enter UTM zone (1-60): ").strip()
if zone_input.lower() == 'quit':
break
hemisphere_input = input("Enter hemisphere (N/S) [default: N]: ").strip().upper()
if hemisphere_input == 'QUIT':
break
# Parse inputs
easting = float(easting_input)
northing = float(northing_input)
zone = int(zone_input)
northern = hemisphere_input != 'S' if hemisphere_input else True
# Convert
lat, lon = converter.convert_single_point(easting, northing, zone, northern)
print(f"\nWGS84 Coordinates:")
print(f"Latitude: {lat:.8f}°")
print(f"Longitude: {lon:.8f}°")
print("-" * 40)
except ValueError as e:
print(f"Error: {e}")
print("Please enter valid numeric values.")
except KeyboardInterrupt:
print("\nExiting...")
break
def main():
"""Main function to handle command line arguments and file processing."""
parser = argparse.ArgumentParser(
description="Convert UTM ED50 coordinates to WGS84 format",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Interactive mode
python utm_ed50_to_wgs84_converter.py --interactive
# Convert CSV file
python utm_ed50_to_wgs84_converter.py input.csv output.csv
# Convert with custom column names
python utm_ed50_to_wgs84_converter.py input.csv output.csv --easting-col X --northing-col Y --zone-col ZONE
"""
)
parser.add_argument('input_file', nargs='?', help='Input CSV file with UTM coordinates')
parser.add_argument('output_file', nargs='?', help='Output CSV file for WGS84 coordinates')
parser.add_argument('--interactive', '-i', action='store_true',
help='Run in interactive mode')
parser.add_argument('--easting-col', default='easting',
help='Column name for easting coordinates (default: easting)')
parser.add_argument('--northing-col', default='northing',
help='Column name for northing coordinates (default: northing)')
parser.add_argument('--zone-col', default='zone',
help='Column name for UTM zone (default: zone)')
parser.add_argument('--northern-col',
help='Column name for northern hemisphere flag (optional)')
parser.add_argument('--northern-default', action='store_true', default=True,
help='Default value for northern hemisphere (default: True)')
args = parser.parse_args()
if args.interactive:
interactive_mode()
return
if not args.input_file or not args.output_file:
parser.error("Both input_file and output_file are required when not in interactive mode")
try:
# Read input file
print(f"Reading input file: {args.input_file}")
df = pd.read_csv(args.input_file)
# Check required columns
required_cols = [args.easting_col, args.northing_col, args.zone_col]
missing_cols = [col for col in required_cols if col not in df.columns]
if missing_cols:
print(f"Error: Missing required columns: {missing_cols}")
print(f"Available columns: {list(df.columns)}")
sys.exit(1)
# Convert coordinates
print("Converting coordinates...")
converter = UTMED50ToWGS84Converter()
result_df = converter.convert_dataframe(
df,
args.easting_col,
args.northing_col,
args.zone_col,
args.northern_col,
args.northern_default
)
# Save output
print(f"Saving output file: {args.output_file}")
result_df.to_csv(args.output_file, index=False)
# Print summary
total_rows = len(result_df)
successful_conversions = result_df['wgs84_lat'].notna().sum()
print(f"\nConversion complete!")
print(f"Total rows: {total_rows}")
print(f"Successful conversions: {successful_conversions}")
print(f"Failed conversions: {total_rows - successful_conversions}")
except FileNotFoundError:
print(f"Error: Input file '{args.input_file}' not found")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -1,340 +0,0 @@
#!/usr/bin/env python3
"""
Enhanced UTM ED50 to WGS84 Coordinate Converter
This script converts UTM coordinates in 6-degree zones from ED50 (European Datum 1950)
reference system to WGS84 format. Supports both CSV and JSON input files.
Requirements:
pip install pyproj pandas
Usage:
python utm_ed50_to_wgs84_converter_enhanced.py input_file.csv output_file.csv
python utm_ed50_to_wgs84_converter_enhanced.py input_file.json output_file.json
python utm_ed50_to_wgs84_converter_enhanced.py --interactive
"""
import argparse
import pandas as pd
import pyproj
import json
import os
from typing import Tuple, List, Optional, Union
import sys
class UTMED50ToWGS84Converter:
"""Converter for UTM ED50 coordinates to WGS84."""
def __init__(self):
"""Initialize the converter with ED50 and WGS84 projections."""
self.ed50_utm_projections = {}
self.wgs84_utm_projections = {}
def get_ed50_utm_projection(self, zone: int, northern: bool = True) -> pyproj.Proj:
"""Get ED50 UTM projection for a specific zone."""
key = (zone, northern)
if key not in self.ed50_utm_projections:
hemisphere = 'N' if northern else 'S'
proj_string = f"+proj=utm +zone={zone} +ellps=intl +towgs84=-87,-98,-121,0,0,0,0 +units=m +no_defs"
self.ed50_utm_projections[key] = pyproj.Proj(proj_string)
return self.ed50_utm_projections[key]
def get_wgs84_utm_projection(self, zone: int, northern: bool = True) -> pyproj.Proj:
"""Get WGS84 UTM projection for a specific zone."""
key = (zone, northern)
if key not in self.wgs84_utm_projections:
hemisphere = 'N' if northern else 'S'
proj_string = f"+proj=utm +zone={zone} +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
self.wgs84_utm_projections[key] = pyproj.Proj(proj_string)
return self.wgs84_utm_projections[key]
def convert_single_point(self, easting: float, northing: float, zone: int,
northern: bool = True) -> Tuple[float, float]:
"""
Convert a single UTM ED50 point to WGS84 lat/lon.
Args:
easting: UTM easting coordinate in meters
northing: UTM northing coordinate in meters
zone: UTM zone number (1-60)
northern: True if in northern hemisphere, False if southern
Returns:
Tuple of (latitude, longitude) in WGS84 decimal degrees
"""
if not (1 <= zone <= 60):
raise ValueError(f"Invalid UTM zone: {zone}. Must be between 1 and 60.")
# Create ED50 UTM projection
ed50_proj = self.get_ed50_utm_projection(zone, northern)
# Create WGS84 lat/lon projection
wgs84_latlon = pyproj.Proj('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs')
# Create transformer from ED50 UTM to WGS84 lat/lon
transformer = pyproj.Transformer.from_proj(ed50_proj, wgs84_latlon)
# Transform coordinates directly to WGS84 lat/lon
wgs84_lon, wgs84_lat = transformer.transform(easting, northing)
return wgs84_lat, wgs84_lon
def convert_dataframe(self, df: pd.DataFrame, easting_col: str, northing_col: str,
zone_col: str, northern_col: Optional[str] = None,
northern_default: bool = True) -> pd.DataFrame:
"""
Convert UTM ED50 coordinates in a DataFrame to WGS84.
Args:
df: Input DataFrame with UTM coordinates
easting_col: Column name for easting coordinates
northing_col: Column name for northing coordinates
zone_col: Column name for UTM zone
northern_col: Column name for northern hemisphere flag (optional)
northern_default: Default value for northern hemisphere if column not provided
Returns:
DataFrame with additional lat/lng columns and renamed description column
"""
result_df = df.copy()
result_df['lat'] = None
result_df['lng'] = None
# Rename description column to name if it exists
if 'description' in result_df.columns:
result_df = result_df.rename(columns={'description': 'name'})
for idx, row in df.iterrows():
try:
easting = float(row[easting_col])
northing = float(row[northing_col])
zone = int(row[zone_col])
if northern_col and northern_col in row:
northern = bool(row[northern_col])
else:
northern = northern_default
lat, lon = self.convert_single_point(easting, northing, zone, northern)
result_df.at[idx, 'lat'] = lat
result_df.at[idx, 'lng'] = lon
except (ValueError, KeyError) as e:
print(f"Warning: Could not convert row {idx}: {e}")
result_df.at[idx, 'lat'] = None
result_df.at[idx, 'lng'] = None
return result_df
def detect_file_format(file_path: str) -> str:
"""Detect if file is CSV or JSON based on extension and content."""
_, ext = os.path.splitext(file_path.lower())
if ext == '.csv':
return 'csv'
elif ext == '.json':
return 'json'
else:
# Try to detect by reading first few lines
try:
with open(file_path, 'r', encoding='utf-8') as f:
first_line = f.readline().strip()
if first_line.startswith('[') or first_line.startswith('{'):
return 'json'
else:
return 'csv'
except:
return 'csv' # Default to CSV
def load_data(file_path: str, csv_separator: str = ';', decimal_separator: str = ',') -> pd.DataFrame:
"""Load data from CSV or JSON file."""
file_format = detect_file_format(file_path)
if file_format == 'json':
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
return pd.DataFrame(data)
elif isinstance(data, dict):
return pd.DataFrame([data])
else:
raise ValueError("JSON file must contain a list or dictionary")
else: # CSV
return pd.read_csv(file_path, sep=csv_separator, decimal=decimal_separator)
def save_data(df: pd.DataFrame, file_path: str, file_format: str = None, csv_separator: str = ';', decimal_separator: str = ','):
"""Save data to CSV or JSON file."""
if file_format is None:
file_format = detect_file_format(file_path)
if file_format == 'json':
# Convert DataFrame to list of dictionaries
data = df.to_dict('records')
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
else: # CSV
df.to_csv(file_path, index=False, sep=csv_separator, decimal=decimal_separator)
def interactive_mode():
"""Run the converter in interactive mode."""
converter = UTMED50ToWGS84Converter()
print("UTM ED50 to WGS84 Coordinate Converter")
print("=" * 40)
print("Enter coordinates (type 'quit' to exit)")
print()
while True:
try:
# Get input
easting_input = input("Enter easting (meters): ").strip()
if easting_input.lower() == 'quit':
break
northing_input = input("Enter northing (meters): ").strip()
if northing_input.lower() == 'quit':
break
zone_input = input("Enter UTM zone (1-60): ").strip()
if zone_input.lower() == 'quit':
break
hemisphere_input = input("Enter hemisphere (N/S) [default: N]: ").strip().upper()
if hemisphere_input == 'QUIT':
break
# Parse inputs
easting = float(easting_input)
northing = float(northing_input)
zone = int(zone_input)
northern = hemisphere_input != 'S' if hemisphere_input else True
# Convert
lat, lon = converter.convert_single_point(easting, northing, zone, northern)
print(f"\nWGS84 Coordinates:")
print(f"Latitude: {lat:.8f}°")
print(f"Longitude: {lon:.8f}°")
print("-" * 40)
except ValueError as e:
print(f"Error: {e}")
print("Please enter valid numeric values.")
except KeyboardInterrupt:
print("\nExiting...")
break
def main():
"""Main function to handle command line arguments and file processing."""
parser = argparse.ArgumentParser(
description="Convert UTM ED50 coordinates to WGS84 format (supports CSV and JSON)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Interactive mode
python utm_ed50_to_wgs84_converter_enhanced.py --interactive
# Convert CSV file (semicolon-separated by default)
python utm_ed50_to_wgs84_converter_enhanced.py input.csv output.csv
# Convert CSV file with comma separator
python utm_ed50_to_wgs84_converter_enhanced.py input.csv output.csv --separator ,
# Convert JSON file
python utm_ed50_to_wgs84_converter_enhanced.py input.json output.json
# Convert with custom column names
python utm_ed50_to_wgs84_converter_enhanced.py input.csv output.csv --easting-col X --northing-col Y --zone-col ZONE
# Using full paths
python /full/path/to/script.py /full/path/to/input.csv /full/path/to/output.csv
"""
)
parser.add_argument('input_file', nargs='?', help='Input file with UTM coordinates (CSV or JSON)')
parser.add_argument('output_file', nargs='?', help='Output file for WGS84 coordinates (CSV or JSON)')
parser.add_argument('--interactive', '-i', action='store_true',
help='Run in interactive mode')
parser.add_argument('--easting-col', default='easting',
help='Column name for easting coordinates (default: easting)')
parser.add_argument('--northing-col', default='northing',
help='Column name for northing coordinates (default: northing)')
parser.add_argument('--zone-col', default='zone',
help='Column name for UTM zone (default: zone)')
parser.add_argument('--northern-col',
help='Column name for northern hemisphere flag (optional)')
parser.add_argument('--northern-default', action='store_true', default=True,
help='Default value for northern hemisphere (default: True)')
parser.add_argument('--separator', '-s', default=';',
help='CSV separator character (default: ;)')
parser.add_argument('--decimal', '-d', default=',',
help='CSV decimal separator character (default: ,)')
args = parser.parse_args()
if args.interactive:
interactive_mode()
return
if not args.input_file or not args.output_file:
parser.error("Both input_file and output_file are required when not in interactive mode")
try:
# Check if input file exists
if not os.path.exists(args.input_file):
print(f"Error: Input file '{args.input_file}' not found")
print(f"Current working directory: {os.getcwd()}")
sys.exit(1)
# Load data
print(f"Reading input file: {args.input_file}")
df = load_data(args.input_file, args.separator, args.decimal)
# Check required columns
required_cols = [args.easting_col, args.northing_col, args.zone_col]
missing_cols = [col for col in required_cols if col not in df.columns]
if missing_cols:
print(f"Error: Missing required columns: {missing_cols}")
print(f"Available columns: {list(df.columns)}")
sys.exit(1)
# Convert coordinates
print("Converting coordinates...")
converter = UTMED50ToWGS84Converter()
result_df = converter.convert_dataframe(
df,
args.easting_col,
args.northing_col,
args.zone_col,
args.northern_col,
args.northern_default
)
# Save output
print(f"Saving output file: {args.output_file}")
save_data(result_df, args.output_file, csv_separator=args.separator, decimal_separator=args.decimal)
# Print summary
total_rows = len(result_df)
successful_conversions = result_df['lat'].notna().sum()
print(f"\nConversion complete!")
print(f"Total rows: {total_rows}")
print(f"Successful conversions: {successful_conversions}")
print(f"Failed conversions: {total_rows - successful_conversions}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -1,184 +0,0 @@
{
"api_config": {
"base_url": "https://risk.tarla.io/api",
"timeout_seconds": 60,
"retry_attempts": 3,
"default_query_range": {
"method": "current_month"
}
},
"output_base_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/",
"default_padding_km": 5,
"wind_farms": [
{
"farm_id": "dagpazari_RES",
"name": "Dağpazarı RES",
"enabled": true,
"coordinates_file": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/coordinates/dagpazari_RES_coordinates.json",
"distance_rings": [2000, 4000, 6000, 8000],
"ring_colors": ["#B71C1C", "#F94144", "#F8961E", "#90BE6D"],
"lightning_source_type": "api",
"api_params": {
"location_bounds": {
"method": "auto",
"padding_km": 5
},
"date_range": {
"method": "manual",
"start_date": "01-02-2026",
"end_date": "28-02-2026"
}
},
"report_config": {
"output_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/dagpazari_RES/",
"timezone": "Europe/Istanbul"
}
},
{
"farm_id": "boreas_enez_RES",
"name": "Boreas Enez RES",
"enabled": false,
"coordinates_file": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/coordinates/boreas_enez_RES_coordinates.json",
"distance_rings": [4000, 6000, 8000, 10000],
"ring_colors": ["#B71C1C", "#F94144", "#F8961E", "#90BE6D"],
"lightning_source_type": "api",
"api_params": {
"location_bounds": {
"method": "auto",
"padding_km": 5
},
"date_range": {
"method": "manual",
"start_date": "08-12-2025",
"end_date": "23-03-2026"
}
},
"report_config": {
"output_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/boreas_enez_RES/",
"timezone": "Europe/Istanbul"
}
},
{
"farm_id": "maslaktepe_RES",
"name": "Maslaktepe RES",
"enabled": false,
"coordinates_file": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/coordinates/maslaktepe_RES_coordinates.json",
"distance_rings": [4000, 6000, 8000, 10000],
"ring_colors": ["#B71C1C", "#F94144", "#F8961E", "#90BE6D"],
"lightning_source_type": "api",
"api_params": {
"location_bounds": {
"method": "auto",
"padding_km": 5
},
"date_range": {
"method": "manual",
"start_date": "08-12-2025",
"end_date": "23-03-2026"
}
},
"report_config": {
"output_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/maslaktepe_RES/",
"timezone": "Europe/Istanbul"
}
},
{
"farm_id": "Susurluk_RES",
"name": "Susurluk RES",
"enabled": false,
"coordinates_file": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/coordinates/susurluk_RES_coordinates.json",
"distance_rings": [1000, 2000, 3000, 4000, 10000],
"ring_colors": ["purple", "red", "orange", "coral", "green"],
"lightning_source_type": "api",
"api_params": {
"location_bounds": {
"method": "auto",
"padding_km": 5
},
"date_range": {
"method": "manual",
"start_date": "05-11-2025",
"end_date": "08-12-2025"
}
},
"report_config": {
"output_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/susurluk_RES/",
"timezone": "Europe/Istanbul"
}
},
{
"farm_id": "benlikuyu_GES",
"name": "Benlikuyu GES",
"enabled": false,
"coordinates_file": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/coordinates/benlikuyu_GES_coordinates.json",
"distance_rings": [1000, 2000, 3000, 4000, 10000],
"ring_colors": ["purple", "red", "orange", "coral", "green"],
"lightning_source_type": "api",
"api_params": {
"location_bounds": {
"method": "auto",
"padding_km": 5
},
"date_range": {
"method": "manual",
"start_date": "08-09-2025",
"end_date": "08-12-2025"
}
},
"report_config": {
"output_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/benlikuyu_GES/",
"timezone": "Europe/Istanbul"
}
},
{
"farm_id": "SOKE-01",
"name": "SOKE-01",
"enabled": false,
"coordinates_file": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/coordinates/SOKE-01.json",
"distance_rings": [1000, 2000, 4000, 8000, 10000],
"ring_colors": ["#D62828", "#F77F00", "#FCBF49", "#90BE6D", "#4D96FF"],
"lightning_source_type": "api",
"api_params": {
"location_bounds": {
"method": "auto",
"padding_km": 0.5
},
"date_range": {
"method": "manual",
"start_date": "2026-01-22T07:00:00Z",
"end_date": "2026-01-22T08:00:00Z"
}
},
"report_config": {
"output_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/SOKE-01/",
"timezone": "Europe/Istanbul"
}
},
{
"farm_id": "Wind Farm Krnovo",
"name": "Wind Farm Krnovo",
"enabled": false,
"coordinates_file": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/coordinates/wind_farm_krnovo.json",
"distance_rings": [2000, 4000, 8000, 20000, 30000],
"ring_colors": ["#D62828", "#F77F00", "#FCBF49", "#90BE6D", "#4D96FF"],
"lightning_source_type": "csv",
"lightning_csv": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/lightnings/26_02_19_karadag.csv",
"api_params": {
"location_bounds": {
"method": "auto",
"padding_km": 0.5
},
"date_range": {
"method": "manual",
"start_date": "19-02-2026",
"end_date": "20-02-2026"
}
},
"report_config": {
"output_directory": "/Users/erdemerikci/Drive'ım/ERIKTRONIK/iklimco/Rapor/reports/wind_farm_krnovo/",
"timezone": "Europe/Podgorica"
}
}
]
}