"""
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)