Source code for cruiseplan.output.kml_generator

"""
KML Generation System for Cruise Planning.

Generates Google Earth compatible KML files from cruise configuration catalogs.
Creates geospatial visualizations of all cruise entities (stations, moorings,
transits, ports, areas) for geographic analysis and planning.

This module provides two generation modes:
1. Catalog-based: Generate KML from YAML configuration entities (recommended)
2. Timeline-based: Generate KML from scheduled timeline activities (legacy)

Notes
-----
KML files can be opened directly in Google Earth or other GIS applications.
Each entity type is styled differently for visual distinction. Point features
include detailed metadata in popups. Catalog-based generation shows each
location once regardless of visitation frequency.
"""

import logging
from pathlib import Path

from cruiseplan.config.cruise_config import CruiseConfig
from cruiseplan.output.output_utils import is_line_operation, is_scientific_operation
from cruiseplan.timeline.scheduler import ActivityRecord

logger = logging.getLogger(__name__)

# KML styles for different operation types
# TODO: Refactor to use centralized PLOT_STYLES from utils/plot_config.py
KML_STYLES = """
        <Style id="stationStyle">
            <IconStyle>
                <color>ff0000ff</color>
                <scale>1.2</scale>
                <Icon>
                    <href>http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png</href>
                </Icon>
            </IconStyle>
        </Style>

        <Style id="mooringStyle">
            <IconStyle>
                <color>ff00ff00</color>
                <scale>1.2</scale>
                <Icon>
                    <href>http://maps.google.com/mapfiles/kml/shapes/placemark_square.png</href>
                </Icon>
            </IconStyle>
        </Style>

        <Style id="lineOpStyle">
            <LineStyle>
                <color>ff0066FF</color>
                <width>3</width>
            </LineStyle>
        </Style>

        <Style id="areaStyle">
            <LineStyle>
                <color>ffFFD700</color>
                <width>2</width>
            </LineStyle>
            <PolyStyle>
                <color>66FFD700</color>
            </PolyStyle>
        </Style>
"""


[docs] class KMLGenerator: """ Manages KML generation for cruise schedules showing only scientific operations. This class generates Google Earth compatible KML files containing geospatial representations of scientific cruise activities. Only scientific operations are included, with different styling for each operation type. """
[docs] def __init__(self): """Initialize the KML generator.""" pass
[docs] def generate_schedule_kml( self, config: CruiseConfig, timeline: list[ActivityRecord], output_file: Path ) -> Path: """ Generate KML schedule output with only scientific operations. Parameters ---------- config : CruiseConfig The cruise configuration object timeline : List[ActivityRecord] Timeline generated by the scheduler output_file : Path Path to output KML file Returns ------- Path Path to generated KML file """ # Define KML styles for different operation types kml_content = f"""<?xml version="1.0" encoding="UTF-8"?> <kml xmlns="http://www.opengis.net/kml/2.2"> <Document> <name>{config.cruise_name} - Schedule</name> <description>{config.description or 'Cruise schedule'}</description> <!-- Styles for different operation types --> <Style id="stationStyle"> <IconStyle> <color>ff0000ff</color> <scale>1.2</scale> <Icon> <href>http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png</href> </Icon> </IconStyle> </Style> <Style id="mooringStyle"> <IconStyle> <color>ff00ff00</color> <scale>1.2</scale> <Icon> <href>http://maps.google.com/mapfiles/kml/shapes/placemark_square.png</href> </Icon> </IconStyle> </Style> <Style id="lineOpStyle"> <LineStyle> <color>ff0066FF</color> <width>3</width> </LineStyle> </Style> <Style id="areaStyle"> <LineStyle> <color>ff00ffff</color> <width>2</width> </LineStyle> <PolyStyle> <color>4000ffff</color> <fill>1</fill> </PolyStyle> </Style> """ # Filter timeline to only include scientific operations scientific_activities = [ activity for activity in timeline if is_scientific_operation(activity) ] for activity in scientific_activities: if is_line_operation(activity): # Line operation - create line with label at midpoint start_lat = activity["start_lat"] start_lon = activity["start_lon"] end_lat = activity["lat"] end_lon = activity["lon"] # Calculate midpoint for label mid_lat = (start_lat + end_lat) / 2 mid_lon = (start_lon + end_lon) / 2 action_str = activity.get("action", "Survey") kml_content += f""" <Placemark> <name>{activity['label']} - {action_str}</name> <description> Activity: {activity['activity']} ({action_str}) Start: {activity['start_time'].strftime('%Y-%m-%d %H:%M')} Duration: {activity['duration_minutes']:.1f} min Operation Distance: {activity.get('dist_nm', 0):.1f} nm </description> <styleUrl>#lineOpStyle</styleUrl> <LineString> <coordinates> {start_lon},{start_lat},0 {end_lon},{end_lat},0 </coordinates> </LineString> </Placemark> <Placemark> <name>{activity['label']}</name> <description>Midpoint label for {action_str} operation</description> <Point> <coordinates>{mid_lon},{mid_lat},0</coordinates> </Point> </Placemark> """ elif activity["activity"] == "Area": # Area operation - create polygon with corners corners = activity.get("corners", []) if corners and len(corners) >= 3: # Create coordinate list for polygon (close the polygon by repeating first point) coord_list = [ f"{corner['longitude']},{corner['latitude']},0" for corner in corners ] # Close the polygon if len(corners) > 0: coord_list.append( f"{corners[0]['longitude']},{corners[0]['latitude']},0" ) coordinates_str = " ".join(coord_list) action_str = activity.get("action", "Survey") kml_content += f""" <Placemark> <name>{activity['label']} - {action_str}</name> <description> Activity: {activity['activity']} ({action_str}) Start: {activity['start_time'].strftime('%Y-%m-%d %H:%M')} Duration: {activity['duration_minutes']:.1f} min Area: {len(corners)} corners </description> <styleUrl>#areaStyle</styleUrl> <Polygon> <outerBoundaryIs> <LinearRing> <coordinates>{coordinates_str}</coordinates> </LinearRing> </outerBoundaryIs> </Polygon> </Placemark> """ else: # Fallback to center point if no corners defined kml_content += f""" <Placemark> <name>{activity['label']}</name> <description> Activity: {activity['activity']} (Area - no corners defined) Start: {activity['start_time'].strftime('%Y-%m-%d %H:%M')} Duration: {activity['duration_minutes']:.1f} min </description> <styleUrl>#stationStyle</styleUrl> <Point> <coordinates>{activity['lon']},{activity['lat']},0</coordinates> </Point> </Placemark> """ else: # Point operation - Station or Mooring style_id = ( "stationStyle" if activity["activity"] == "Station" else "mooringStyle" ) depth_str = ( f"Depth: {activity.get('depth', 0):.1f} m" if activity.get("depth") else "" ) kml_content += f""" <Placemark> <name>{activity['label']}</name> <description> Activity: {activity['activity']} Start: {activity['start_time'].strftime('%Y-%m-%d %H:%M')} Duration: {activity['duration_minutes']:.1f} min {depth_str} </description> <styleUrl>#{style_id}</styleUrl> <Point> <coordinates>{activity['lon']},{activity['lat']},0</coordinates> </Point> </Placemark> """ kml_content += """ </Document> </kml> """ # Write to file output_file.parent.mkdir(parents=True, exist_ok=True) with open(output_file, "w", encoding="utf-8") as f: f.write(kml_content) return output_file
[docs] def generate_kml_catalog(config: CruiseConfig, output_file: Path) -> Path: """ Generate KML file from YAML configuration catalog entities. Creates a KML file containing all entities defined in the cruise configuration (stations, moorings, transits, ports, areas) without timeline duplication. Each location appears once regardless of how many times it's visited. Parameters ---------- config : CruiseConfig The cruise configuration object containing all catalog entities output_file : Path Path to output KML file Returns ------- Path Path to generated KML file """ # Define KML styles for different entity types kml_content = f"""<?xml version="1.0" encoding="UTF-8"?> <kml xmlns="http://www.opengis.net/kml/2.2"> <Document> <name>{config.cruise_name} - Catalog</name> <description>Cruise configuration catalog including all stations, moorings, transits, ports, and areas</description> <!-- Styles for different entity types --> <Style id="stationStyle"> <IconStyle> <color>ff0000ff</color> <scale>1.2</scale> <Icon> <href>http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png</href> </Icon> </IconStyle> </Style> <Style id="mooringStyle"> <IconStyle> <color>ff00ff00</color> <scale>1.2</scale> <Icon> <href>http://maps.google.com/mapfiles/kml/shapes/placemark_square.png</href> </Icon> </IconStyle> </Style> <Style id="portStyle"> <IconStyle> <color>ff0080ff</color> <scale>1.5</scale> <Icon> <href>http://maps.google.com/mapfiles/kml/shapes/marina.png</href> </Icon> </IconStyle> </Style> <Style id="transitStyle"> <LineStyle> <color>800066FF</color> <width>3</width> </LineStyle> </Style> <Style id="areaStyle"> <LineStyle> <color>ff00ffff</color> <width>2</width> </LineStyle> <PolyStyle> <color>4000ffff</color> <fill>1</fill> </PolyStyle> </Style> <!-- Ports -->""" # Add ports from cruise configuration if hasattr(config, "departure_port") and config.departure_port: port = config.departure_port kml_content += f""" <Placemark> <name>Departure: {port.name}</name> <description> Port: {port.name} Type: Departure Port Location: {port.latitude:.6f}°N, {port.longitude:.6f}°W Timezone: {getattr(port, 'timezone', 'N/A')} </description> <styleUrl>#portStyle</styleUrl> <Point> <coordinates>{port.longitude},{port.latitude},0</coordinates> </Point> </Placemark>""" if hasattr(config, "arrival_port") and config.arrival_port: port = config.arrival_port kml_content += f""" <Placemark> <name>Arrival: {port.name}</name> <description> Port: {port.name} Type: Arrival Port Location: {port.latitude:.6f}°N, {port.longitude:.6f}°W Timezone: {getattr(port, 'timezone', 'N/A')} </description> <styleUrl>#portStyle</styleUrl> <Point> <coordinates>{port.longitude},{port.latitude},0</coordinates> </Point> </Placemark>""" # Add ports from configuration catalog if hasattr(config, "ports") and config.ports: for port in config.ports: timezone_str = getattr(port, "timezone", "N/A") kml_content += f""" <Placemark> <name>{port.name}</name> <description> Port: {port.name} Type: Catalog Port Location: {port.latitude:.6f}°N, {port.longitude:.6f}°W Timezone: {timezone_str} </description> <styleUrl>#portStyle</styleUrl> <Point> <coordinates>{port.longitude},{port.latitude},0</coordinates> </Point> </Placemark>""" kml_content += """ <!-- Stations -->""" # Add stations from configuration if hasattr(config, "points") and config.points: for station in config.points: # Convert enum values to strings operation_type = getattr(station, "operation_type", "station") if hasattr(operation_type, "value"): operation_type = operation_type.value action = getattr(station, "action", "profile") if hasattr(action, "value"): action = action.value # Use water_depth field (added by enrichment) with fallback depth = getattr(station, "water_depth", None) or getattr( station, "depth", None ) depth_str = f"{depth:.0f}" if depth is not None else "N/A" kml_content += f""" <Placemark> <name>{station.name}</name> <description> Type: {operation_type.upper()} Action: {action.upper()} Location: {station.latitude:.6f}°N, {station.longitude:.6f}°W Depth: {depth_str} m </description> <styleUrl>#stationStyle</styleUrl> <Point> <coordinates>{station.longitude},{station.latitude},0</coordinates> </Point> </Placemark>""" kml_content += """ <!-- Moorings -->""" # Add moorings from configuration if hasattr(config, "moorings") and config.moorings: for mooring in config.moorings: # Convert enum values to strings operation_type = getattr(mooring, "operation_type", "mooring") if hasattr(operation_type, "value"): operation_type = operation_type.value action = getattr(mooring, "action", "deployment") if hasattr(action, "value"): action = action.value # Use water_depth field (added by enrichment) with fallback depth = getattr(mooring, "water_depth", None) or getattr( mooring, "depth", None ) depth_str = f"{depth:.0f}" if depth is not None else "N/A" kml_content += f""" <Placemark> <name>{mooring.name}</name> <description> Type: {operation_type.upper()} Action: {action.upper()} Location: {mooring.latitude:.6f}°N, {mooring.longitude:.6f}°W Depth: {depth_str} m </description> <styleUrl>#mooringStyle</styleUrl> <Point> <coordinates>{mooring.longitude},{mooring.latitude},0</coordinates> </Point> </Placemark>""" kml_content += """ <!-- Transits -->""" # Add lines from configuration if hasattr(config, "lines") and config.lines: for line in config.lines: # Extract start and end coordinates from route waypoints if line.route and len(line.route) >= 2: start_lat = line.route[0].latitude start_lon = line.route[0].longitude end_lat = line.route[-1].latitude end_lon = line.route[-1].longitude # Calculate route distance if available route_distance = getattr(line, "distance", None) if route_distance is None or route_distance == "calculated": # Calculate distance if not provided import math lat1, lon1 = math.radians(start_lat), math.radians(start_lon) lat2, lon2 = math.radians(end_lat), math.radians(end_lon) dlat, dlon = lat2 - lat1, lon2 - lon1 a = ( math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 ) c = 2 * math.asin(math.sqrt(a)) route_distance = 3440.065 * c # Earth radius in nautical miles route_distance = f"{route_distance:.1f}" # Get the actual operation type from the config operation_type = getattr(line, "operation_type", "line") if hasattr(operation_type, "value"): operation_type = operation_type.value kml_content += f""" <Placemark> <name>{line.name}</name> <description> Type: {operation_type} Start: {start_lat:.6f}°N, {start_lon:.6f}°W End: {end_lat:.6f}°N, {end_lon:.6f}°W Distance: {route_distance} nm </description> <styleUrl>#transitStyle</styleUrl> <LineString> <coordinates> {start_lon},{start_lat},0 {end_lon},{end_lat},0 </coordinates> </LineString> </Placemark>""" kml_content += """ <!-- Areas -->""" # Add areas from configuration if hasattr(config, "areas") and config.areas: for area in config.areas: if hasattr(area, "corners") and area.corners: # Create coordinate list for polygon coord_list = [ f"{corner.longitude},{corner.latitude},0" for corner in area.corners ] # Close the polygon if area.corners: coord_list.append( f"{area.corners[0].longitude},{area.corners[0].latitude},0" ) coordinates_str = " ".join(coord_list) # Convert enum to string for display operation_type = getattr(area, "operation_type", "survey") if hasattr(operation_type, "value"): operation_type = operation_type.value kml_content += f""" <Placemark> <name>{area.name}</name> <description> Type: Area Corners: {len(area.corners)} Operation: {operation_type} </description> <styleUrl>#areaStyle</styleUrl> <Polygon> <outerBoundaryIs> <LinearRing> <coordinates>{coordinates_str}</coordinates> </LinearRing> </outerBoundaryIs> </Polygon> </Placemark>""" # Close KML document kml_content += """ </Document> </kml> """ # Write to file output_file.parent.mkdir(parents=True, exist_ok=True) with open(output_file, "w", encoding="utf-8") as f: f.write(kml_content) return output_file
[docs] def generate_kml_schedule( config: CruiseConfig, timeline: list[ActivityRecord], output_file: Path ) -> Path: """ Main interface to generate KML schedule from scheduler timeline. Parameters ---------- config : CruiseConfig The cruise configuration object timeline : List[ActivityRecord] Timeline generated by the scheduler output_file : Path Path to output KML file Returns ------- Path Path to generated KML file """ generator = KMLGenerator() return generator.generate_schedule_kml(config, timeline, output_file)