465 lines
22 KiB
Python
465 lines
22 KiB
Python
# geoelevation/map_viewer/map_utils.py
|
|
"""
|
|
Provides utility functions for map-related calculations.
|
|
|
|
Includes functions for determining geographic bounding boxes from center points
|
|
and sizes, finding necessary map tile ranges to cover an area using the
|
|
'mercantile' library, and calculating ground resolution (meters per pixel).
|
|
"""
|
|
|
|
# Standard library imports
|
|
import logging
|
|
import math
|
|
from typing import Tuple, Optional, List, Set # Ensure all used types are imported
|
|
|
|
# Third-party imports
|
|
try:
|
|
import pyproj # For geodetic calculations (e.g., bounding box from center)
|
|
PYPROJ_AVAILABLE = True
|
|
except ImportError:
|
|
pyproj = None # type: ignore
|
|
PYPROJ_AVAILABLE = False
|
|
logging.warning("MapUtils: 'pyproj' library not found. Some geodetic calculations will fail.")
|
|
|
|
try:
|
|
import mercantile # For tile calculations (Web Mercator)
|
|
MERCANTILE_AVAILABLE_UTILS = True
|
|
except ImportError:
|
|
mercantile = None # type: ignore
|
|
MERCANTILE_AVAILABLE_UTILS = False
|
|
logging.warning("MapUtils: 'mercantile' library not found. Tile calculations will fail.")
|
|
|
|
# Module-level logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MapCalculationError(Exception):
|
|
"""Custom exception for errors occurring during map-related calculations."""
|
|
pass
|
|
|
|
|
|
def get_bounding_box_from_center_size(
|
|
center_latitude_deg: float,
|
|
center_longitude_deg: float,
|
|
area_size_km: float
|
|
) -> Optional[Tuple[float, float, float, float]]: # (west_lon, south_lat, east_lon, north_lat)
|
|
"""
|
|
Calculates a geographic bounding box (WGS84 decimal degrees) given a center
|
|
point and a size (width/height of a square area).
|
|
|
|
Args:
|
|
center_latitude_deg: Latitude of the center point in degrees.
|
|
center_longitude_deg: Longitude of the center point in degrees.
|
|
area_size_km: The side length of the desired square area in kilometers.
|
|
|
|
Returns:
|
|
A tuple (west_lon, south_lat, east_lon, north_lat) in degrees,
|
|
or None if an error occurs or pyproj is unavailable.
|
|
|
|
Raises:
|
|
ImportError: Propagated if pyproj is required but not installed.
|
|
(Note: function now returns None instead of raising directly for this)
|
|
"""
|
|
if not PYPROJ_AVAILABLE:
|
|
logger.error(
|
|
"'pyproj' library is required for bounding box calculation from center/size but is not found."
|
|
)
|
|
return None # Return None instead of raising ImportError here to allow graceful degradation
|
|
|
|
if not (isinstance(area_size_km, (int, float)) and area_size_km > 0):
|
|
logger.error(f"Invalid area_size_km: {area_size_km}. Must be a positive number.")
|
|
return None
|
|
if not (-90.0 <= center_latitude_deg <= 90.0):
|
|
logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}. Must be in [-90, 90].")
|
|
return None
|
|
if not (-180.0 <= center_longitude_deg <= 180.0):
|
|
logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}. Must be in [-180, 180].")
|
|
return None
|
|
|
|
|
|
logger.debug(
|
|
f"Calculating bounding box for center ({center_latitude_deg:.6f}, {center_longitude_deg:.6f}), "
|
|
f"size {area_size_km} km."
|
|
)
|
|
|
|
try:
|
|
# Initialize a geodetic calculator using the WGS84 ellipsoid
|
|
geodetic_calculator = pyproj.Geod(ellps="WGS84") # type: ignore
|
|
|
|
half_side_length_meters = (area_size_km / 2.0) * 1000.0
|
|
|
|
# Calculate points by projecting from the center along cardinal directions
|
|
# Azimuths: 0=North, 90=East, 180=South, 270=West
|
|
# geod.fwd returns (end_longitude, end_latitude, back_azimuth)
|
|
_, north_boundary_lat, _ = geodetic_calculator.fwd(
|
|
lons=center_longitude_deg, lats=center_latitude_deg, az=0.0, dist=half_side_length_meters
|
|
)
|
|
_, south_boundary_lat, _ = geodetic_calculator.fwd(
|
|
lons=center_longitude_deg, lats=center_latitude_deg, az=180.0, dist=half_side_length_meters
|
|
)
|
|
east_boundary_lon, _, _ = geodetic_calculator.fwd(
|
|
lons=center_longitude_deg, lats=center_latitude_deg, az=90.0, dist=half_side_length_meters
|
|
)
|
|
west_boundary_lon, _, _ = geodetic_calculator.fwd(
|
|
lons=center_longitude_deg, lats=center_latitude_deg, az=270.0, dist=half_side_length_meters
|
|
)
|
|
|
|
# Handle potential latitude clamping at poles
|
|
# pyproj should handle this correctly, but a sanity check can be useful
|
|
north_boundary_lat = min(90.0, max(-90.0, north_boundary_lat))
|
|
south_boundary_lat = min(90.0, max(-90.0, south_boundary_lat))
|
|
|
|
# Handle longitude wrapping if the area is extremely large (unlikely for typical use)
|
|
# pyproj fwd/inv handles dateline crossing correctly for points.
|
|
# If west_lon > east_lon after projection, it implies dateline crossing.
|
|
# For bounding box, we typically want west < east unless it spans more than 180 deg.
|
|
# This simple assignment should be fine for areas not spanning the dateline/poles widely.
|
|
|
|
logger.debug(
|
|
f"Calculated BBox: W={west_boundary_lon:.6f}, S={south_boundary_lat:.6f}, "
|
|
f"E={east_boundary_lon:.6f}, N={north_boundary_lat:.6f}"
|
|
)
|
|
return (west_boundary_lon, south_boundary_lat, east_boundary_lon, north_boundary_lat)
|
|
|
|
except Exception as e_bbox_calc:
|
|
logger.exception(f"Error calculating bounding box from center/size: {e_bbox_calc}")
|
|
return None
|
|
|
|
|
|
def get_tile_ranges_for_bbox(
|
|
bounding_box_deg: Tuple[float, float, float, float], # (west, south, east, north)
|
|
zoom_level: int
|
|
) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]: # ((min_x, max_x), (min_y, max_y))
|
|
"""
|
|
Calculates the X and Y tile ranges (min_x, max_x, min_y, max_y)
|
|
required to cover a given geographic bounding box at a specific zoom level.
|
|
|
|
Args:
|
|
bounding_box_deg: A tuple (west_lon, south_lat, east_lon, north_lat) in degrees.
|
|
zoom_level: The Web Mercator map zoom level.
|
|
|
|
Returns:
|
|
A tuple containing ((min_x, max_x), (min_y, max_y)) tile coordinates,
|
|
or None if an error occurs or mercantile is unavailable.
|
|
"""
|
|
if not MERCANTILE_AVAILABLE_UTILS:
|
|
logger.error("'mercantile' library is required for tile range calculation but is not found.")
|
|
return None
|
|
|
|
west_lon, south_lat, east_lon, north_lat = bounding_box_deg
|
|
logger.debug(
|
|
f"Calculating tile ranges for zoom {zoom_level} and BBox W={west_lon:.4f}, "
|
|
f"S={south_lat:.4f}, E={east_lon:.4f}, N={north_lat:.4f}"
|
|
)
|
|
|
|
try:
|
|
# mercantile.tiles expects (west, south, east, north) and zoom as integer
|
|
# It returns a generator of Tile(x, y, z) objects.
|
|
tiles_in_bbox_generator = mercantile.tiles( # type: ignore
|
|
west_lon, south_lat, east_lon, north_lat, zooms=[zoom_level] # Pass zoom as a list
|
|
)
|
|
|
|
list_of_tiles = list(tiles_in_bbox_generator)
|
|
|
|
if not list_of_tiles:
|
|
# If the bbox is very small or outside standard tile limits, mercantile.tiles might be empty.
|
|
# As a fallback, find the tile containing the center of the bbox.
|
|
logger.warning(
|
|
f"No tiles found by mercantile.tiles for BBox at zoom {zoom_level}. "
|
|
"Using fallback: tile at BBox center."
|
|
)
|
|
center_lon = (west_lon + east_lon) / 2.0
|
|
center_lat = (south_lat + north_lat) / 2.0
|
|
# Clamp center_lat to avoid mercantile issues near poles if bbox extends beyond valid range
|
|
center_lat = max(-85.0, min(85.0, center_lat)) # Mercantile limits
|
|
center_lon = max(-180.0, min(180.0, center_lon)) # Mercantile limits
|
|
|
|
# mercantile.tile(lon, lat, zoom)
|
|
center_point_tile = mercantile.tile(center_lon, center_lat, zoom_level) # type: ignore
|
|
|
|
min_tile_x = center_point_tile.x
|
|
max_tile_x = center_point_tile.x
|
|
min_tile_y = center_point_tile.y
|
|
max_tile_y = center_point_tile.y
|
|
num_tiles_found = 1
|
|
else:
|
|
# Extract all x and y coordinates from the list of tiles
|
|
x_coordinates = [tile.x for tile in list_of_tiles]
|
|
y_coordinates = [tile.y for tile in list_of_tiles]
|
|
|
|
min_tile_x = min(x_coordinates)
|
|
max_tile_x = max(x_coordinates)
|
|
min_tile_y = min(y_coordinates)
|
|
max_tile_y = max(y_coordinates)
|
|
num_tiles_found = len(list_of_tiles)
|
|
|
|
logger.debug(
|
|
f"Calculated tile ranges for zoom {zoom_level}: "
|
|
f"X=[{min_tile_x}, {max_tile_x}], Y=[{min_tile_y}, {max_tile_y}] "
|
|
f"({num_tiles_found} tiles)"
|
|
)
|
|
return ((min_tile_x, max_tile_x), (min_tile_y, max_tile_y))
|
|
|
|
except Exception as e_tile_range_calc:
|
|
logger.exception(f"Error calculating tile ranges: {e_tile_range_calc}")
|
|
return None
|
|
|
|
|
|
def calculate_meters_per_pixel(
|
|
latitude_degrees: float,
|
|
zoom_level: int,
|
|
tile_pixel_size: int = 256
|
|
) -> Optional[float]:
|
|
"""
|
|
Calculates the approximate ground resolution (meters per pixel) at a given
|
|
latitude and zoom level for the Web Mercator projection.
|
|
|
|
Args:
|
|
latitude_degrees: Latitude in degrees.
|
|
zoom_level: Map zoom level.
|
|
tile_pixel_size: The size of one side of a map tile in pixels (usually 256).
|
|
|
|
Returns:
|
|
Approximate meters per pixel, or None if calculation fails or inputs are invalid.
|
|
"""
|
|
try:
|
|
if not (-90.0 <= latitude_degrees <= 90.0):
|
|
logger.warning(f"Invalid latitude for m/px calc: {latitude_degrees}")
|
|
return None
|
|
# Zoom levels for Web Mercator are typically 0-22, some services go higher.
|
|
if not (0 <= zoom_level <= 25): # Practical upper limit
|
|
logger.warning(f"Invalid zoom level for m/px calc: {zoom_level}")
|
|
return None
|
|
if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0):
|
|
logger.warning(f"Invalid tile_pixel_size for m/px calc: {tile_pixel_size}")
|
|
return None
|
|
|
|
|
|
# Earth's equatorial circumference in meters (WGS84)
|
|
EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
|
|
|
latitude_radians = math.radians(latitude_degrees)
|
|
|
|
# Formula: Resolution = (Circumference * cos(latitude_rad)) / (tile_size * 2^zoom)
|
|
resolution_m_px = (EARTH_CIRCUMFERENCE_METERS * math.cos(latitude_radians)) / \
|
|
(tile_pixel_size * (2**zoom_level))
|
|
|
|
# Avoid returning non-finite values if cos(latitude) is near zero at poles
|
|
if not math.isfinite(resolution_m_px) or resolution_m_px <= 0:
|
|
logger.warning(f"Calculated non-finite or non-positive m/px ({resolution_m_px}) at Lat {latitude_degrees}. Returning None.")
|
|
return None
|
|
|
|
logger.debug(
|
|
f"Calculated meters/pixel at lat {latitude_degrees:.4f}, zoom {zoom_level}, "
|
|
f"tile_size {tile_pixel_size}px: {resolution_m_px:.4f} m/px"
|
|
)
|
|
return resolution_m_px
|
|
|
|
except Exception as e_mpp_calc:
|
|
logger.exception(f"Error calculating meters per pixel: {e_mpp_calc}")
|
|
return None
|
|
|
|
# MODIFIED: Added function to calculate geographic size of a bounding box.
|
|
# WHY: Needed to report the displayed map area size in the GUI.
|
|
# HOW: Implemented logic using pyproj to calculate distances for width and height.
|
|
def calculate_geographic_bbox_size_km(
|
|
bounding_box_deg: Tuple[float, float, float, float] # (west, south, east, north)
|
|
) -> Optional[Tuple[float, float]]: # Returns (approx_width_km, approx_height_km)
|
|
"""
|
|
Calculates the approximate geographic width and height of a bounding box in kilometers.
|
|
Uses pyproj if available. Width is calculated along the center latitude, height along center longitude.
|
|
|
|
Args:
|
|
bounding_box_deg: A tuple (west_lon, south_lat, east_lon, north_lat) in degrees.
|
|
|
|
Returns:
|
|
A tuple (approx_width_km, approx_height_km), or None if calculation fails or pyproj is unavailable.
|
|
"""
|
|
if not PYPROJ_AVAILABLE:
|
|
logger.error(
|
|
"'pyproj' library is required for geographic size calculation but is not found."
|
|
)
|
|
return None
|
|
|
|
west_lon, south_lat, east_lon, north_lat = bounding_box_deg
|
|
|
|
# Basic validation
|
|
if not (-90.0 <= south_lat <= north_lat <= 90.0):
|
|
logger.warning(f"Invalid latitude range for size calculation: {south_lat}, {north_lat}")
|
|
return None
|
|
# For longitude, check range is within a reasonable bound, but allow west > east for dateline crossing
|
|
# The geographic width should be calculated carefully to handle wrapping around the globe.
|
|
# The height calculation is simpler as latitude is bounded.
|
|
|
|
try:
|
|
geodetic_calculator = pyproj.Geod(ellps="WGS84") # type: ignore
|
|
|
|
# Calculate approximate width along the center latitude
|
|
# The 'inv' method from pyproj.Geod is suitable for this, it handles the antimeridian.
|
|
center_lat = (south_lat + north_lat) / 2.0
|
|
# Clamp center lat to avoid issues near poles for geod.inv
|
|
center_lat = max(-89.9, min(89.9, center_lat))
|
|
|
|
# geod.inv returns (forward_azimuth, backward_azimuth, distance)
|
|
_, _, width_meters = geodetic_calculator.inv(west_lon, center_lat, east_lon, center_lat)
|
|
|
|
|
|
# Calculate approximate height along the center longitude
|
|
# This is simpler, distance between south_lat and north_lat at center_lon
|
|
# Need to handle potential longitude wrap around - use inv carefully
|
|
# The straight line distance calculation below should be generally fine for height.
|
|
center_lon = (west_lon + east_lon) / 2.0 # Use the average longitude for height calculation line
|
|
# Clamp center lon
|
|
center_lon = max(-179.9, min(179.9, center_lon))
|
|
_, _, height_meters = geodetic_calculator.inv(center_lon, south_lat, center_lon, north_lat)
|
|
|
|
approx_width_km = abs(width_meters) / 1000.0 # Ensure positive distance
|
|
approx_height_km = abs(height_meters) / 1000.0
|
|
|
|
# Add a sanity check: if width or height are zero, something is wrong (e.g., points are identical)
|
|
if approx_width_km <= 0 or approx_height_km <= 0:
|
|
logger.warning(f"Calculated non-positive width or height for BBox {bounding_box_deg}. Result: ({approx_width_km:.2f}, {approx_height_km:.2f}). Returning None.")
|
|
return None
|
|
|
|
|
|
logger.debug(
|
|
f"Calculated BBox size for {bounding_box_deg}: "
|
|
f"Approx. {approx_width_km:.2f}km W x {approx_height_km:.2f}km H"
|
|
)
|
|
return (approx_width_km, approx_height_km)
|
|
|
|
except Exception as e_size_calc:
|
|
logger.exception(f"Error calculating geographic bounding box size: {e_size_calc}")
|
|
return None
|
|
|
|
# MODIFIED: Added function to calculate the geographic bounds of an HGT tile from its integer coordinates.
|
|
# WHY: Needed to get the exact bounds of the DEM tile to determine the map fetch area and potentially draw the boundary.
|
|
# HOW: Based on HGT tile naming conventions (e.g., N45E007 covers 7E-8E, 45N-46N), the integer coordinates are the southwest corner.
|
|
def get_hgt_tile_geographic_bounds(lat_coord: int, lon_coord: int) -> Tuple[float, float, float, float]:
|
|
"""
|
|
Calculates the precise geographic bounding box (W, S, E, N) for an HGT tile
|
|
based on its integer latitude and longitude coordinates.
|
|
|
|
Assumes standard HGT 1x1 degree tile coverage where lat_coord is the
|
|
southern boundary latitude and lon_coord is the western boundary longitude.
|
|
E.g., tile N45E007 (lat_coord=45, lon_coord=7) covers 7E-8E, 45N-46N.
|
|
|
|
Args:
|
|
lat_coord: The integer latitude coordinate of the tile (e.g., 45 for N45).
|
|
lon_coord: The integer longitude coordinate of the tile (e.g., 7 for E007).
|
|
|
|
Returns:
|
|
A tuple (west_lon, south_lat, east_lon, north_lat) in degrees.
|
|
"""
|
|
west_lon = float(lon_coord)
|
|
south_lat = float(lat_coord)
|
|
east_lon = float(lon_coord + 1)
|
|
north_lat = float(lat_coord + 1)
|
|
|
|
# Clamping to strict WGS84 bounds for sanity, though tile coords should respect this
|
|
west_lon = max(-180.0, min(180.0, west_lon))
|
|
south_lat = max(-90.0, min(90.0, south_lat))
|
|
east_lon = max(-180.0, min(180.0, east_lon))
|
|
north_lat = max(-90.0, min(90.0, north_lat))
|
|
|
|
logger.debug(f"Calculated HGT tile bounds for ({lat_coord},{lon_coord}): ({west_lon:.6f}, {south_lat:.6f}, {east_lon:.6f}, {north_lat:.6f})")
|
|
return (west_lon, south_lat, east_lon, north_lat)
|
|
|
|
|
|
# MODIFIED: Added function to calculate required zoom level for a target geographic size to fit in a target pixel size.
|
|
# WHY: To determine the appropriate zoom level for displaying the 1x1 degree DEM tile area within a manageable pixel size.
|
|
# HOW: Used the inverse of the calculate_meters_per_pixel formula to solve for the zoom level.
|
|
def calculate_zoom_level_for_geographic_size(
|
|
latitude_degrees: float,
|
|
geographic_height_meters: float, # The height of the area you want to fit in pixels
|
|
target_pixel_height: int, # The desired pixel height for that geographic area
|
|
tile_pixel_size: int = 256
|
|
) -> Optional[int]:
|
|
"""
|
|
Calculates the approximate Web Mercator zoom level required for a given
|
|
geographic height (in meters at a specific latitude) to span a target pixel height.
|
|
|
|
This is useful for determining the zoom needed to fit a known geographic area
|
|
(like a DEM tile's height) into a certain number of pixels on a map composed of tiles.
|
|
|
|
Args:
|
|
latitude_degrees: The latitude at which the geographic_height_meters is measured.
|
|
geographic_height_meters: The actual height of the geographic area in meters at that latitude.
|
|
target_pixel_height: The desired height in pixels for that geographic area on the map.
|
|
tile_pixel_size: The size of one side of a map tile in pixels (usually 256).
|
|
|
|
Returns:
|
|
The approximate integer zoom level, or None if calculation fails or inputs are invalid.
|
|
"""
|
|
if not (-90.0 <= latitude_degrees <= 90.0):
|
|
logger.warning(f"Invalid latitude for zoom calculation: {latitude_degrees}")
|
|
return None
|
|
if not (isinstance(geographic_height_meters, (int, float)) and geographic_height_meters > 0):
|
|
logger.warning(f"Invalid geographic_height_meters for zoom calculation: {geographic_height_meters}. Must be positive.")
|
|
return None
|
|
if not (isinstance(target_pixel_height, int) and target_pixel_height > 0):
|
|
logger.warning(f"Invalid target_pixel_height for zoom calculation: {target_pixel_height}. Must be positive integer.")
|
|
return None
|
|
if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0):
|
|
logger.warning(f"Invalid tile_pixel_size for zoom calculation: {tile_pixel_size}. Must be positive integer.")
|
|
return None
|
|
|
|
try:
|
|
# Earth's equatorial circumference in meters (WGS84)
|
|
EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
|
|
|
# Calculate the required meters per pixel (resolution) to fit the geographic height into the target pixel height
|
|
required_resolution_m_px = geographic_height_meters / target_pixel_height
|
|
|
|
# Avoid division by zero or non-finite results
|
|
if required_resolution_m_px <= 0 or not math.isfinite(required_resolution_m_px):
|
|
logger.warning(f"Calculated non-positive or non-finite required resolution ({required_resolution_m_px} m/px). Cannot calculate zoom.")
|
|
return None
|
|
|
|
|
|
# Use the inverse of the resolution formula: Resolution = (Circumference * cos(latitude_rad)) / (tile_size * 2^z)
|
|
# Rearranging for 2^z: 2^z = (Circumference * cos(latitude_rad)) / (tile_size * Resolution)
|
|
# Solving for z: z = log2( (Circumference * cos(latitude_rad)) / (tile_size * Resolution) )
|
|
|
|
latitude_radians = math.radians(latitude_degrees)
|
|
cos_lat = math.cos(latitude_radians)
|
|
|
|
# Avoid division by zero or log of zero/negative if cos_lat is near zero (at poles)
|
|
# The latitude validation should prevent hitting exactly 90/-90, but check for very small values.
|
|
if abs(cos_lat) < 1e-9: # Handle near poles
|
|
logger.warning(f"Latitude {latitude_degrees} is too close to a pole for reliable zoom calculation.")
|
|
# Return a very low zoom level as a fallback? Or None?
|
|
# Given the context (mapping a DEM tile), this likely won't happen as DEMs stop at 60 deg.
|
|
return None # Return None for latitudes very close to poles
|
|
|
|
term_for_log = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * required_resolution_m_px)
|
|
|
|
# Ensure the argument for log2 is positive
|
|
if term_for_log <= 0:
|
|
logger.warning(f"Calculated non-positive term for log2 ({term_for_log}) during zoom calculation. Cannot calculate zoom.")
|
|
return None
|
|
|
|
# Calculate the precise zoom level (can be fractional)
|
|
precise_zoom = math.log2(term_for_log)
|
|
|
|
# We need an integer zoom level for tile fetching. Rounding to the nearest integer is common.
|
|
# Floor might get fewer tiles than needed, ceil might get more. Rounding is a good balance.
|
|
integer_zoom = int(round(precise_zoom))
|
|
|
|
# Clamp the calculated zoom level to a reasonable range (e.g., 0 to 20)
|
|
# A zoom level too high (e.g., > 22) might not be supported by map services.
|
|
# A level too low (negative) indicates an issue or a request for an impossibly large area.
|
|
# We should probably cap it to the map service's max zoom as well, but that info isn't available here.
|
|
# Let's clamp it to a general reasonable range.
|
|
clamped_zoom = max(0, min(integer_zoom, 20)) # Max zoom of 20 is usually safe for OSM
|
|
|
|
logger.debug(
|
|
f"Calculated zoom for {geographic_height_meters:.2f}m at Lat {latitude_degrees:.4f} "
|
|
f"to fit in {target_pixel_height}px: Precise Zoom {precise_zoom:.2f}, Clamped Integer Zoom {clamped_zoom}"
|
|
)
|
|
|
|
return clamped_zoom
|
|
|
|
except Exception as e_zoom_calc:
|
|
logger.exception(f"Error calculating zoom level: {e_zoom_calc}")
|
|
return None |