///|
priv struct HorizonCandidate {
row : Int
col : Int
elevation_m : Double
distance_m : Double
horizon_angle_deg : Double
}
///|
priv struct RouteSolarExposure {
evaluated_sample_count : Int
spherical_sunlit_hours : Double
terrain_sunlit_hours : Double
terrain_shadowed_hours : Double
longest_dark_hours : Double
terrain_available_energy_wh : Double
minimum_solar_clearance_deg : Double
maximum_solar_clearance_deg : Double
}
///|
fn horizon_cell(
grid : @terrain.TerrainGrid,
row : Int,
col : Int,
) -> @core.TerrainCell {
grid.cells[row * grid.cols + col]
}
///|
fn horizon_center_elevation(grid : @terrain.TerrainGrid) -> Double {
let low_row = (grid.rows - 1) / 2
let high_row = grid.rows / 2
let low_col = (grid.cols - 1) / 2
let high_col = grid.cols / 2
(
horizon_cell(grid, low_row, low_col).elevation_m +
horizon_cell(grid, low_row, high_col).elevation_m +
horizon_cell(grid, high_row, low_col).elevation_m +
horizon_cell(grid, high_row, high_col).elevation_m
) /
4.0
}
///|
fn horizon_route_id(tile_id : String) -> String {
match tile_id {
"first-trusted-square-lola" => "direct-lola-window"
"first-trusted-square-west-contour-lola" => "west-contour-detour"
"first-trusted-square-north-rim-lola" => "north-rim-stepout"
"first-trusted-square-southwest-bypass-lola" => "southwest-bypass"
"first-trusted-square-south-stepout-lola" => "south-stepout"
"first-trusted-square-northeast-stepout-lola" => "northeast-stepout"
_ => tile_id
}
}
///|
fn horizon_artifact_path(route_id : String) -> String {
let artifact_id = route_id.replace_all(old="-", new="_")
"artifact/mission/first_trusted_square_\{artifact_id}_horizon.json"
}
///|
fn horizon_sector_label(sector : Int) -> String {
match sector {
0 => "N"
1 => "NE"
2 => "E"
3 => "SE"
4 => "S"
5 => "SW"
6 => "W"
_ => "NW"
}
}
///|
fn horizon_sector(azimuth_deg : Double) -> Int {
let shifted = azimuth_deg + 22.5
let sector = (shifted / 45.0).to_int()
if sector >= 8 {
0
} else {
sector
}
}
///|
fn horizon_sample(
sector : Int,
candidate : HorizonCandidate,
) -> HorizonAzimuthSample {
{
sector_id: "sector-\{sector}",
label: horizon_sector_label(sector),
azimuth_center_deg: sector.to_double() * 45.0,
obstruction_row: candidate.row,
obstruction_col: candidate.col,
obstruction_elevation_m: candidate.elevation_m,
distance_m: candidate.distance_m,
horizon_angle_deg: candidate.horizon_angle_deg,
}
}
///|
fn empty_horizon_sample(sector : Int) -> HorizonAzimuthSample {
{
sector_id: "sector-\{sector}",
label: horizon_sector_label(sector),
azimuth_center_deg: sector.to_double() * 45.0,
obstruction_row: -1,
obstruction_col: -1,
obstruction_elevation_m: 0.0,
distance_m: 0.0,
horizon_angle_deg: -90.0,
}
}
///|
fn route_solar_exposure(
horizons : Array[HorizonAzimuthSample],
solar_track : @lunar_ephemeris.SolarGeometryTrack,
) -> RouteSolarExposure {
route_solar_exposure_range(
horizons,
solar_track,
0,
solar_track.sun_altitude_deg
.length()
.min(solar_track.sun_azimuth_deg.length()),
)
}
///|
fn route_solar_exposure_range(
horizons : Array[HorizonAzimuthSample],
solar_track : @lunar_ephemeris.SolarGeometryTrack,
sample_offset : Int,
requested_sample_count : Int,
) -> RouteSolarExposure {
let track_sample_count = solar_track.sun_altitude_deg
.length()
.min(solar_track.sun_azimuth_deg.length())
let start = sample_offset.max(0).min(track_sample_count)
let end = (start + requested_sample_count.max(0)).min(track_sample_count)
let sample_count = end - start
let mut spherical_sunlit_hours = 0.0
let mut terrain_sunlit_hours = 0.0
let mut terrain_shadowed_hours = 0.0
let mut current_dark_hours = 0.0
let mut longest_dark_hours = 0.0
let mut terrain_available_energy_wh = 0.0
let mut minimum_solar_clearance_deg = 90.0
let mut maximum_solar_clearance_deg = -90.0
for index in start.. 0.0
let terrain_sunlit = clearance_deg > 0.0
if spherical_sunlit {
spherical_sunlit_hours += 1.0
}
if terrain_sunlit {
terrain_sunlit_hours += 1.0
current_dark_hours = 0.0
terrain_available_energy_wh += 450.0 *
@math.sin(sun_altitude_deg * 3.141592653589793 / 180.0)
} else {
current_dark_hours += 1.0
longest_dark_hours = longest_dark_hours.max(current_dark_hours)
if spherical_sunlit {
terrain_shadowed_hours += 1.0
}
}
}
if sample_count == 0 {
minimum_solar_clearance_deg = 0.0
maximum_solar_clearance_deg = 0.0
}
{
evaluated_sample_count: sample_count,
spherical_sunlit_hours,
terrain_sunlit_hours,
terrain_shadowed_hours,
longest_dark_hours,
terrain_available_energy_wh,
minimum_solar_clearance_deg,
maximum_solar_clearance_deg,
}
}
///|
pub fn bounded_local_horizon_evidence(
grid : @terrain.TerrainGrid,
constraint : IlluminationConstraint,
solar_track : @lunar_ephemeris.SolarGeometryTrack,
) -> LocalHorizonEvidence {
let center_elevation_m = horizon_center_elevation(grid)
let center_row = (grid.rows - 1).to_double() / 2.0
let center_col = (grid.cols - 1).to_double() / 2.0
let sectors : Array[HorizonCandidate?] = Array::make(8, None)
let mut maximum : HorizonCandidate? = None
for cell in grid.cells {
let east_m = (cell.col.to_double() - center_col) * grid.cell_size_m
let north_m = (center_row - cell.row.to_double()) * grid.cell_size_m
let distance_m = (east_m * east_m + north_m * north_m).sqrt()
if distance_m > 0.0 {
let horizon_angle_deg = @math.atan2(
cell.elevation_m - center_elevation_m,
distance_m,
) *
180.0 /
3.141592653589793
let raw_azimuth_deg = @math.atan2(east_m, north_m) *
180.0 /
3.141592653589793
let azimuth_deg = if raw_azimuth_deg < 0.0 {
raw_azimuth_deg + 360.0
} else {
raw_azimuth_deg
}
let candidate = {
row: cell.row,
col: cell.col,
elevation_m: cell.elevation_m,
distance_m,
horizon_angle_deg,
}
let sector = horizon_sector(azimuth_deg)
match sectors[sector] {
Some(current) =>
if candidate.horizon_angle_deg > current.horizon_angle_deg {
sectors[sector] = Some(candidate)
}
None => sectors[sector] = Some(candidate)
}
match maximum {
Some(current) =>
if candidate.horizon_angle_deg > current.horizon_angle_deg {
maximum = Some(candidate)
}
None => maximum = Some(candidate)
}
}
}
let azimuth_samples : Array[HorizonAzimuthSample] = []
for sector in 0..<8 {
match sectors[sector] {
Some(candidate) => azimuth_samples.push(horizon_sample(sector, candidate))
None => azimuth_samples.push(empty_horizon_sample(sector))
}
}
let maximum = maximum.unwrap()
let exposure = route_solar_exposure(azimuth_samples, solar_track)
let missing_samples = exposure.evaluated_sample_count == 0
let insufficient_sun = exposure.terrain_sunlit_hours <
constraint.min_sunlit_hours
let excessive_darkness = exposure.longest_dark_hours >
constraint.max_dark_hours
let decision = if missing_samples || insufficient_sun || excessive_darkness {
Block
} else if exposure.terrain_shadowed_hours > 0.0 {
Review
} else {
Allow
}
let route_id = horizon_route_id(grid.tile_id)
{
evidence_id: "first-trusted-square-\{route_id}-local-horizon-v3",
route_id,
site_id: "first-trusted-square",
source_dataset_id: grid.source_manifest.dataset_id,
source_tile_id: grid.tile_id,
source_path: grid.source_manifest.source_path,
power_window_evidence_id: constraint.power_window_evidence_id,
power_window_source_path: constraint.power_window_source_path,
output_path: horizon_artifact_path(route_id),
method_id: "hourly-azimuth-matched-local-horizon-v3",
generated_by: "vectie/moonmoon/src/mission/horizon.mbt",
rows: grid.rows,
cols: grid.cols,
cell_size_m: grid.cell_size_m,
center_elevation_m,
max_obstruction_elevation_m: maximum.elevation_m,
max_horizon_angle_deg: maximum.horizon_angle_deg,
min_sun_altitude_deg: constraint.min_sun_altitude_deg,
max_sun_altitude_deg: constraint.max_sun_altitude_deg,
evaluated_sample_count: exposure.evaluated_sample_count,
spherical_sunlit_hours: exposure.spherical_sunlit_hours,
terrain_sunlit_hours: exposure.terrain_sunlit_hours,
terrain_shadowed_hours: exposure.terrain_shadowed_hours,
longest_dark_hours: exposure.longest_dark_hours,
terrain_available_energy_wh: exposure.terrain_available_energy_wh,
minimum_solar_clearance_deg: exposure.minimum_solar_clearance_deg,
maximum_solar_clearance_deg: exposure.maximum_solar_clearance_deg,
azimuth_samples,
confidence: @terrain.analyze(grid).uncertainty.confidence * 0.875,
decision,
reasons: [
"eight-sector bounded local horizon computed from \{grid.source_manifest.source_path}",
"\{exposure.evaluated_sample_count} hourly Sun samples were matched to terrain horizon by azimuth",
"route receives \{exposure.terrain_sunlit_hours} terrain-visible hours and \{exposure.terrain_available_energy_wh} Wh",
"longest continuous darkness is \{exposure.longest_dark_hours} hours",
],
next_action: match decision {
Allow => "hourly route exposure clears the illumination window"
Review =>
"review terrain-shadowed daylight intervals before route simulation"
Block =>
if missing_samples {
"attach hourly Sun geometry before route simulation"
} else if exposure.spherical_sunlit_hours == 0.0 {
"select a mission window where the Sun rises above the spherical horizon"
} else {
"select a lower-obstruction route or a window with enough terrain-visible sunlight"
}
},
}
}