"""
Default sledding suitability scoring configuration.
This config defines how terrain and snow statistics are combined into
a sledding suitability score. Users can modify this config or create
their own based on local conditions.
Score formula:
final = (weighted sum of additive) × (product of multiplicative)
Components:
- Additive (weighted sum to 1.0):
- slope_mean: Ideal slope angle (trapezoidal, 5-15° sweet spot)
- snow_depth: Adequate snow coverage (trapezoidal, 150-500mm sweet spot)
- snow_coverage: Reliability of snow days (linear)
- snow_consistency: Snow reliability (RMS of inter/intra-season CVs)
- aspect_bonus: North-facing snow retention bonus (linear)
- runout_bonus: Safe stopping area available (linear)
- Multiplicative (penalties, only extreme values):
- cliff_penalty: Dangerous steep sections (dealbreaker on p95)
- terrain_consistency: Extreme roughness only (soft threshold at 50%)
"""
import numpy as np
from src.scoring.combiner import ScoreComponent, ScoreCombiner
[docs]
def create_default_sledding_scorer() -> ScoreCombiner:
"""
Create the default sledding suitability scorer.
Returns:
ScoreCombiner configured for sledding suitability analysis.
Example:
>>> scorer = create_default_sledding_scorer()
>>> score = scorer.compute({
... "slope_mean": 10.0, # degrees
... "snow_depth": 300.0, # mm
... "snow_coverage": 0.7, # ratio 0-1
... "snow_consistency": 0.3, # CV (lower is better)
... "aspect_bonus": 0.03, # northness × strength
... "runout_bonus": 1.0, # 1.0 if slope_min < 5°
... "slope_p95": 20.0, # degrees (for cliff detection)
... "roughness": 10.0, # meters
... "slope_std": 3.0, # degrees
... })
"""
return ScoreCombiner(
name="sledding_suitability",
components=[
# =================================================================
# ADDITIVE COMPONENTS (weighted sum = 1.0)
# =================================================================
# Slope angle: 5-15° is ideal for sledding
# Too flat = boring, too steep = dangerous
# WEIGHT: 30% - most important factor for sledding experience
ScoreComponent(
name="slope_mean",
transform="trapezoidal",
transform_params={
"sweet_range": (5, 15), # Ideal: 5-15 degrees
"ramp_range": (3, 25), # Usable: 3-25 degrees
},
role="additive",
weight=0.30,
),
# Snow depth: need enough cushion but not too deep
# Units: millimeters (SNODAS native)
# WEIGHT: 15% - enough snow matters but less critical than other factors
ScoreComponent(
name="snow_depth",
transform="trapezoidal",
transform_params={
"sweet_range": (150, 500), # ~6-20 inches ideal
"ramp_range": (50, 1000), # 2-40 inches usable
},
role="additive",
weight=0.15,
),
# Snow coverage: fraction of winter days with snow
# Higher is better (more sledding opportunities)
# WEIGHT: 25% - reliability of snow days is very important
ScoreComponent(
name="snow_coverage",
transform="linear",
transform_params={
"value_range": (0, 1),
"power": 0.5, # sqrt for diminishing returns
},
role="additive",
weight=0.25,
),
# Snow consistency: year-to-year reliability
# Input is already a 0-1 score from snow_consistency() where 1=consistent=good
# WEIGHT: 20% - predictable snow year-over-year is important
ScoreComponent(
name="snow_consistency",
transform="linear",
transform_params={
"value_range": (0, 1), # Already inverted consistency score
},
role="additive",
weight=0.20,
),
# Aspect bonus: north-facing slopes retain snow better
# Pre-computed as: cos(aspect_radians) × aspect_strength × 0.05
# Range: -0.05 (south) to +0.05 (north)
# WEIGHT: 5% - minor bonus
ScoreComponent(
name="aspect_bonus",
transform="linear",
transform_params={
"value_range": (-0.05, 0.05),
},
role="additive",
weight=0.05,
),
# Runout bonus: safe stopping area
# Pre-computed as: 1.0 if slope_min < 5° else 0.0
# WEIGHT: 5% - safety bonus
ScoreComponent(
name="runout_bonus",
transform="linear",
transform_params={
"value_range": (0, 1),
},
role="additive",
weight=0.05,
),
# =================================================================
# MULTIPLICATIVE COMPONENTS (penalties)
# =================================================================
# Cliff penalty: dangerous steep sections
# Based on slope_p95 (95th percentile slope in tile)
# Soft falloff from 25° to 35°
ScoreComponent(
name="slope_p95",
transform="dealbreaker",
transform_params={
"threshold": 25, # Start penalizing at 25°
"falloff": 10, # Full penalty at 35°
},
role="multiplicative",
),
# Terrain consistency: smooth vs undulating
# Pre-computed via terrain_consistency(roughness, slope_std)
# Already in 0-1 range (1 = consistent, 0 = rough)
ScoreComponent(
name="terrain_consistency",
transform="linear",
transform_params={
"value_range": (0, 1),
},
role="multiplicative",
),
],
)
# Default scorer instance
DEFAULT_SLEDDING_SCORER = create_default_sledding_scorer()
# Export as dict for JSON serialization
DEFAULT_SLEDDING_CONFIG = DEFAULT_SLEDDING_SCORER.to_dict()
[docs]
def compute_improved_sledding_score(
slope_stats,
snow_stats: dict,
) -> np.ndarray:
"""
Compute improved sledding score using trapezoid functions and synergy bonuses.
This is the new scoring system that uses:
- Trapezoid functions for sweet spots (snow, slope)
- Hard deal breakers (slope > 40°, roughness > 6m, insufficient coverage)
- Coverage with diminishing returns
- Synergy bonuses for exceptional combinations
- Multiplicative base score
Args:
slope_stats: SlopeStatistics object from compute_tiled_slope_statistics()
snow_stats: Dictionary with SNODAS statistics
Returns:
Sledding suitability score array (0-~1.5, can exceed 1.0 due to bonuses)
"""
from src.terrain.scoring import compute_sledding_score
# Convert snow depth from mm to inches (1 mm = 0.0393701 inches)
snow_depth_mm = snow_stats["median_max_depth"]
snow_depth_inches = snow_depth_mm * 0.0393701
# Estimate coverage in months
# snow_coverage is the fraction of winter days with snow
# Assume winter is ~4 months (Dec-Mar), so coverage_months = snow_coverage × 4
# But actually, mean_snow_day_ratio is over the full year, not just winter
# Let's use it more conservatively: if you have snow 25% of the year, that's ~3 months
coverage_months = snow_stats["mean_snow_day_ratio"] * 12.0
# Use mean slope for primary scoring
slope_degrees = slope_stats.slope_mean
# Use roughness (elevation std dev) for terrain quality - physical metric in meters
roughness_meters = slope_stats.roughness
# Compute improved score
score = compute_sledding_score(
snow_depth=snow_depth_inches,
slope=slope_degrees,
coverage_months=coverage_months,
roughness=roughness_meters,
)
return score