1(function initMissionBestWindows(global, factory) { 2 const api = factory(global); 3 if (typeof module !== "undefined" && module.exports) module.exports = api; 4 if (global) global.DSCMissionBestWindows = api; 5})(typeof window !== "undefined" ? window : globalThis, function buildMissionBestWindows(global) { 6 "use strict"; 7 8 const ENGINE_VERSION = "MISSION_BEST_WINDOWS_V1"; 9 const LIGHT_VERSION = "MISSION_LIGHT_SUNRISE_SUNSET_V1"; 10 const DRONE_VERSION = "MISSION_DRONE_WIND_TECHNICAL_V1"; 11 const PREF_VERSION = "MISSION_WEATHER_PREFERENCES_EVALUATOR_V1"; 12 const POINT_VERSION = "MISSION_REPRESENTATIVE_POINT_V1"; 13 const WEATHER_ENGINE_VERSION = "MISSION_WEATHER_SNAPSHOT_ENGINE_V1"; 14 const LEGAL_ENGINE_VERSION = "mission-legal-snapshot-v1"; 15 const MAX_INPUT_BYTES = 512 * 1024; 16 const MAX_SNAPSHOT_BYTES = 200 * 1024; 17 const MAX_GEOMETRY_BYTES = 64 * 1024; 18 const MAX_OUTPUT_BYTES = 200 * 1024; 19 const MAX_DURATION_MS = 168 * 60 * 60 * 1000; 20 const MAX_WEATHER_SLOTS = 169; 21 const MAX_WINDOWS = 256; 22 const WIND_LEVELS = Object.freeze([10, 80, 120]); 23 const UNITS = Object.freeze({ 24 temperatureC: "celsius", precipitationMm: "mm", precipitationProbabilityPct: "%", 25 weatherCode: "WMO code", visibilityMeters: "m", cloudCoverPct: "%", 26 windSpeedKmh: "km/h", windDirectionDegrees: "degrees", windGustKmh: "km/h" 27 }); 28 const REASON_ORDER = Object.freeze([ 29 "INPUT_SCOPE_MISMATCH", "LEGAL_SNAPSHOT_MISSING", "WEATHER_SNAPSHOT_MISSING", 30 "LEGAL_SNAPSHOT_NOT_CURRENT", "WEATHER_SNAPSHOT_NOT_CURRENT", "POINT_ONLY", 31 "LEGAL_UNAVAILABLE", "LEGAL_UNKNOWN", "LEGAL_LIMIT_BELOW_PLANNED_ALTITUDE", 32 "LEGAL_LIMIT_UNKNOWN", "AUTHORIZATION_REQUIRED", "PREFERENCE_WIND_EXCEEDED", 33 "PREFERENCE_GUST_EXCEEDED", "PREFERENCE_PRECIPITATION_EXCEEDED", 34 "PREFERENCE_MIN_TEMPERATURE_EXCEEDED", "PREFERENCE_MAX_TEMPERATURE_EXCEEDED", 35 "PREFERENCE_DATA_UNAVAILABLE", "DRONE_NOT_SELECTED", "DRONE_MATCH_UNAVAILABLE", 36 "DRONE_WIND_LIMIT_UNAVAILABLE", "DRONE_WIND_LIMIT_EXCEEDED", "DRONE_CATALOG_STALE", 37 "WEATHER_PARTIAL", "WEATHER_FIELDS_MISSING", "WEATHER_STALE", "LIGHT_UNKNOWN", 38 "DAYLIGHT", "NIGHT" 39 ]); 40 const REASON_RANK = new Map(REASON_ORDER.map((value, index) => [value, index])); 41 const RANKS = Object.freeze({ 42 placement: ["CANDIDATE", "LIMITED", "REQUIRES_ATTENTION", "NOT_CANDIDATE", "
42UNKNOWN"], 43 preferences: ["NO_EXCEEDANCE", "NOT_CONFIGURED", "EXCEEDANCE_PRESENT", "UNAVAILABLE"], 44 droneTechnical: ["WITHIN_AVAILABLE_WIND_DATA", "NOT_CONFIGURED", "DECLARED_WIND_LIMIT_EXCEEDED", "UNKNOWN"], 45 legalCompleteness: ["COMPLETE", "PARTIAL", "UNKNOWN"], 46 weatherTemporalCoverage: ["FULL", "PARTIAL", "NONE"], 47 weatherFieldQuality: ["COMPLETE", "PARTIAL", "UNKNOWN"], 48 weatherFreshness: ["RECENT", "AGED", "STALE"], 49 droneCatalog: ["CURRENT", "NOT_CONFIGURED", "STALE", "UNKNOWN"] 50 }); 51 52 class MissionBestWindowsInputError extends TypeError { 53 constructor(code, path, message = code) { 54 super(message); 55 this.name = "MissionBestWindowsInputError"; 56 this.code = code; 57 this.path = path; 58 } 59 } 60 61 class MissionBestWindowsLimitError extends RangeError { 62 constructor(code, path, message = code) { 63 super(message); 64 this.name = "MissionBestWindowsLimitError"; 65 this.code = code; 66 this.path = path; 67 } 68 } 69 70 function fail(code, path, message) { throw new MissionBestWindowsInputError(code, path, message); } 71 function limit(code, path) { throw new MissionBestWindowsLimitError(code, path); } 72 function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } 73 function exactKeys(value, keys, path) { 74 if (!isObject(value)) fail("MALFORMED_INPUT", path); 75 const actual = Object.keys(value).sort(); 76 const expected = [...keys].sort(); 77 if (actual.length !== expected.length || actual.some((key, i) => key !== expected[i])) fail("MALFORMED_INPUT", path); 78 } 79 function assertPlain(value, path = "input", seen = new Set()) { 80 if (value === null || typeof value === "string" || typeof value === "boolean") return; 81 if (typeof value === "number") { if (!Number.isFinite(value)) fail("MALFORMED_INPUT", path); return; } 82 if (typeof value !== "object" || value instanceof Date) fail("MALFORMED_INPUT", path); 83 if (seen.has(value)) fail("MALFORMED_INPUT", path); 84 seen.add(value); 85 if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) fail("MALFORMED_INPUT", path); 86 for (const key of Object.keys(value)) { 87 if (value[key] === undefined) fail("MALFORMED_INPUT", `${path}.${key}`); 88 assertPlain(value[key], `${path}.${key}`, seen); 89 } 90 seen.delete(value); 91 } 92 function contract() { 93 const value = global?.DSCMissionGeometryContract; 94 if (!value?.fingerprintValue || !value?.fingerprint || !value?.canonicalizeDomain || !value?.representativePoint) { 95 fail("UNSUPPORTED_CONTRACT", "mission.operationalGeometry", "MISSION_GEOMETRY_CONTRACT_UNAVAILABLE"); 96 } 97 return value; 98 } 99 function temporalCore() { 100 const value = global?.DSCMissionAtomicTimeline; 101 if (!value?.buildMissionAtomicTimeline || !value?.buildMissionLightSegments) { 102 fail("UNSUPPORTED_CONTRACT", "input.mission", "MISSION_ATOMIC_TIMELINE_CORE_UNAVAILABLE"); 103 } 104 return value; 105 } 106 function temporalCall(callback, fallbackPath) { 107 try { return callback(); } 108 catch (error) { 109 if (error?.code === "ATOMIC_INTERVAL_LIMIT_EXCEEDED") limit(error.code, "output.summary.atomicIntervalCount"); 110 if (error?.code) fail(error.code, error.path || fallbackPath, error.message); 111 throw error; 112 } 113 } 114 function stable(value) { return contract().stableStringify(value); } 115 function bytes(value) { return new TextEncoder().encode(stable(value)).byteLength; } 116 function fingerprint(prefix, value) { return contract().fingerprintValue(prefix, value); } 117 function iso(value, path) { 118 if (typeof value !== "string") fail("MALFORMED_INPUT", path); 119 const ms = Date.parse(value); 120 if (!Number.isFinite(ms) || new Date(ms).toISOString() !== value) fail("MALFORMED_INPUT", path); 121 return { value, ms }; 122 } 123 function text(value, path) { 124 if (typeof value !== "string" || !value.trim()) fail("MALFORMED_INPUT", path); 125 return value.trim(); 126 } 127 function finiteOrNull(value, path) { 128 if (value === null) return null;
129 if (typeof value !== "number" || !Number.isFinite(value)) fail("MALFORMED_INPUT", path); 130 return value; 131 } 132 function stringList(value, path) { 133 if (!Array.isArray(value) || value.some(item => typeof item !== "string")) fail("MALFORMED_INPUT", path); 134 return value; 135 } 136 function orderedReasons(values) { 137 const unique = [...new Set(values.filter(value => REASON_RANK.has(value)))]; 138 return unique.sort((a, b) => REASON_RANK.get(a) - REASON_RANK.get(b)); 139 } 140 function sortedUnique(values, canonicalize = value => value) { 141 return [...new Set(values.map(canonicalize))].sort((a, b) => typeof a === "number" ? a - b : String(a).localeCompare(String(b))); 142 } 143 144 function validateMission(value) { 145 exactKeys(value, ["schemaVersion", "id", "operatorId", "plannedAt", "plannedUntil", "plannedAltitude", "operationalGeometry"], "input.mission"); 146 if (value.schemaVersion !== 2 && value.schemaVersion !== 3) fail("UNSUPPORTED_SCHEMA", "input.mission.schemaVersion"); 147 const id = text(value.id, "input.mission.id"); 148 const operatorId = text(value.operatorId, "input.mission.operatorId"); 149 const from = iso(value.plannedAt, "input.mission.plannedAt"); 150 const to = iso(value.plannedUntil, "input.mission.plannedUntil"); 151 if (to.ms <= from.ms || to.ms - from.ms > MAX_DURATION_MS) fail("MALFORMED_INPUT", "input.mission.plannedUntil"); 152 exactKeys(value.plannedAltitude, ["valueMeters", "reference"], "input.mission.plannedAltitude"); 153 const altitude = value.plannedAltitude.valueMeters; 154 if (typeof altitude !== "number" || !Number.isFinite(altitude) || altitude <= 0 || altitude > 10000 || Number(altitude.toFixed(1)) !== altitude || value.plannedAltitude.reference !== "AGL") fail("MALFORMED_INPUT", "input.mission.plannedAltitude"); 155 if (bytes(value.operationalGeometry) > MAX_GEOMETRY_BYTES) fail("INPUT_TOO_LARGE", "input.mission.operationalGeometry"); 156 let geometry; 157 try { geometry = contract().canonicalizeDomain(value.operationalGeometry); } 158 catch { fail("MALFORMED_INPUT", "input.mission.operationalGeometry"); } 159 return { value, id, operatorId, from, to, altitude, geometry, geometryFingerprint: contract().fingerprint(geometry) }; 160 } 161 162 function validatePreferences(value) { 163 exactKeys(value, ["schemaVersion", "weather"], "input.preferences"); 164 if (value.schemaVersion !== 1) fail("UNSUPPORTED_SCHEMA", "input.preferences.schemaVersion"); 165 const keys = ["maxWindKmh", "maxGustKmh", "avoidPrecipitation", "minTemperatureC", "maxTemperatureC"]; 166 exactKeys(value.weather, keys, "input.preferences.weather"); 167 for (const key of ["maxWindKmh", "maxGustKmh", "minTemperatureC", "maxTemperatureC"]) { 168 const item = value.weather[key]; 169 if (item !== null && (typeof item !== "number" || !Number.isFinite(item))) fail("MALFORMED_INPUT", `input.preferences.weather.${key}`); 170 } 171 if ((value.weather.maxWindKmh !== null && value.weather.maxWindKmh < 0) || (value.weather.maxGustKmh !== null && value.weather.maxGustKmh < 0)) fail("MALFORMED_INPUT", "input.preferences.weather"); 172 if (value.weather.avoidPrecipitation !== null && typeof value.weather.avoidPrecipitation !== "boolean") fail("MALFORMED_INPUT", "input.preferences.weather.avoidPrecipitation"); 173 if (value.weather.minTemperatureC !== null && value.weather.maxTemperatureC !== null && value.weather.minTemperatureC >= value.weather.maxTemperatureC) fail("MALFORMED_INPUT", "input.preferences.weather"); 174 return value; 175 } 176 177 function validateDrone(value) { 178 if (value === null) return null; 179 exactKeys(value, ["schemaVersion", "contractVersion", "droneId", "catalog", "matchStatus", "windResistance"], "input.droneTechnical"); 180 if (value.schemaVersion !== 1) fail("UNSUPPORTED_SCHEMA", "input.droneTechnical.schemaVersion"); 181 if (value.contractVersion !== DRONE_VERSION) fail("UNSUPPORTED_CONTRACT", "input.droneTechnical.contractVersion"); 182 text(value.droneId, "input.droneTechnical.droneId"); 183 exactKeys(value.catalog, ["schemaVersion", "catalogVersion", "sha256", "loadSource", "stale"], "input.droneTechnical.catalog"); 184 if (value.catalog.schemaVersion !== 2 || !Number.isInteger(value.catalog.catalogVersion) || value.catalog.catalogVersion < 1 || !/^[0-9a-f]{64}$/.test(value.catalog.sha256)) fail("MALFORMED_INPUT", "input.droneTechnical.catalog"); 185 if (!["NETWORK", "CACHE", "LAST_KNOWN_GOOD"].includes(value.catalog.loadSource) || typeof value.catalog.stale !== "boolean" || (value.catalog.loadSource === "LAST_KNOWN_GOOD") !== value.catalog.stale) fail("MALFORMED_INPUT", "input.droneTechnical.catalog"); 186 if (!["EXACT", "ALIAS", "NOT_FOUND", "AMBIGUOUS"].includes(value.matchStatus)) fail("MALFORMED_INPUT", "input.droneTechnical.matchStatus"); 187 if (value.windResistance !== null) { 188 exactKeys(value.windResistance, ["valueMs", "basis"], "input.droneTechnical.windResistance"); 189 if (typeof value.windResistance.valueMs !== "number" || !Number.isFinite(value.windResistance.valueMs) || value.windResistance.valueMs <= 0 || !["TAKEOFF_LANDING", "GENERAL", "GENERAL_RANGE_MINIMUM", "CRUISE_ONLY"].includes(value.windResistance.basis)) fail("MALFORMED_INPUT", "input.droneTechnical.windResistance"); 190 }
191 if (["NOT_FOUND", "AMBIGUOUS"].includes(value.matchStatus) && value.windResistance !== null) fail("MALFORMED_INPUT", "input.droneTechnical.windResistance"); 192 return value; 193 } 194 195 function legalFingerprint(mission) { 196 return fingerprint("ml1", { schemaVersion: 1, domain: "MISSION_LEGAL_INPUT", geometryFingerprint: mission.geometryFingerprint, 197 plannedAt: mission.from.value, plannedUntil: mission.to.value, coverage: "POINT_ONLY", representativePointContractVersion: POINT_VERSION }); 198 } 199 function weatherFingerprint(mission) { 200 return fingerprint("mw1", { schemaVersion: 1, domain: "MISSION_WEATHER_TARGET", geometryFingerprint: mission.geometryFingerprint, 201 plannedAt: mission.from.value, plannedUntil: mission.to.value, 202 plannedAltitude: { valueMeters: mission.altitude, reference: "AGL" }, 203 representativePointContractVersion: POINT_VERSION, samplingMethodVersion: "REPRESENTATIVE_POINT_ONLY_V1" }); 204 } 205 206 function validateAuthorization(value, path) { 207 if (!isObject(value) || typeof value.required !== "boolean" || !["RESOLVED", "MANUAL_CHECK", "BLOCKED"].includes(value.resolutionStatus) || !Array.isArray(value.procedures) || !Array.isArray(value.additionalRequirements) || !Array.isArray(value.blockingReasons) || !Array.isArray(value.reasonCodes)) fail("MALFORMED_INPUT", path); 208 stringList(value.reasonCodes, `${path}.reasonCodes`); 209 for (const [key, items] of [["procedures", value.procedures], ["additionalRequirements", value.additionalRequirements], ["blockingReasons", value.blockingReasons]]) { 210 items.forEach((item, index) => { 211 if (!isObject(item) || (item.type != null && typeof item.type !== "string") || 212 (item.reasonCode != null && typeof item.reasonCode !== "string") || 213 (item.version != null && typeof item.version !== "string" && typeof item.version !== "number")) fail("MALFORMED_INPUT", `${path}.${key}[${index}]`); 214 }); 215 } 216 return value; 217 } 218 function validateLegal(value) { 219 if (value === null) return null; 220 if (bytes(value) > MAX_SNAPSHOT_BYTES) fail("INPUT_TOO_LARGE", "input.legalSnapshot"); 221 if (!isObject(value) || value.schemaVersion !== 1) fail("UNSUPPORTED_SCHEMA", "input.legalSnapshot.schemaVersion"); 222 if (value.engine?.version !== LEGAL_ENGINE_VERSION) fail("UNSUPPORTED_CONTRACT", "input.legalSnapshot.engine.version"); 223 text(value.id, "input.legalSnapshot.id"); text(value.operatorId, "input.legalSnapshot.operatorId"); text(value.missionId, "input.legalSnapshot.missionId"); 224 if (!isObject(value.mission) || typeof value.mission.inputFingerprint !== "string" || value.mission.inputFingerprintVersion !== 1 || 225 !isObject(value.target) || !isObject(value.geometry) || !Array.isArray(value.segments) || !Array.isArray(value.contributors) || 226 !Array.isArray(value.sources) || !isObject(value.quality)) fail("MALFORMED_INPUT", "input.legalSnapshot"); 227 const targetFrom = iso(value.target.from, "input.legalSnapshot.target.from"); 228 const targetTo = iso(value.target.to, "input.legalSnapshot.target.to"); 229 if (targetTo.ms <= targetFrom.ms || value.target.timezone !== "Europe/Rome" || typeof value.geometry.fingerprint !== "string" || 230 value.geometry.coverage?.level !== "POINT_ONLY" || value.geometry.coverage?.contractVersion !== POINT_VERSION || 231 !["COMPLETE", "PARTIAL"].includes(value.quality.sourceCompleteness)) fail("MALFORMED_INPUT", "input.legalSnapshot"); 232 const expectedFingerprint = fingerprint("ml1", { schemaVersion: 1, domain: "MISSION_LEGAL_INPUT", 233 geometryFingerprint: value.geometry.fingerprint, plannedAt: targetFrom.value, plannedUntil: targetTo.value, 234 coverage: "POINT_ONLY", representativePointContractVersion: value.geometry.coverage.contractVersion }); 235 if (expectedFingerprint !== value.mission.inputFingerprint) fail("INTEGRITY_MISMATCH", "input.legalSnapshot.mission.inputFingerprint"); 236 let cursor = targetFrom.ms; 237 const segments = value.segments.map((segment, index) => { 238 const path = `input.legalSnapshot.segments[${index}]`; 239 if (!isObject(segment) || !["AVAILABLE", "AVAILABLE_WITH_LIMIT", "AUTH_REQUIRED", "UNAVAILABLE", "UNKNOWN"].includes(segment.status) || !Array.isArray(segment.contributorIds) || !Array.isArray(segment.reasonCodes)) fail("MALFORMED_INPUT", path); 240 const from = iso(segment.from, `${path}.from`); const to = iso(segment.to, `${path}.to`); 241 if (from.ms !== cursor || to.ms <= from.ms || to.ms > targetTo.ms) fail("MALFORMED_INPUT", path); 242 cursor = to.ms; 243 validateAuthorization(segment.authorization, `${path}.authorization`);
244 stringList(segment.contributorIds, `${path}.contributorIds`); 245 stringList(segment.reasonCodes, `${path}.reasonCodes`); 246 finiteOrNull(segment.maxAltitudeMetersAgl, `${path}.maxAltitudeMetersAgl`); 247 return { ...segment, fromMs: from.ms, toMs: to.ms }; 248 }); 249 if (!segments.length || cursor !== targetTo.ms) fail("MALFORMED_INPUT", "input.legalSnapshot.segments"); 250 value.contributors.forEach((item, index) => { 251 if (!isObject(item) || typeof item.id !== "string" || !item.id || !Array.isArray(item.effects) || 252 !Array.isArray(item.reasonCodes) || !Array.isArray(item.warnings)) fail("MALFORMED_INPUT", `input.legalSnapshot.contributors[${index}]`); 253 stringList(item.effects, `input.legalSnapshot.contributors[${index}].effects`); 254 stringList(item.reasonCodes, `input.legalSnapshot.contributors[${index}].reasonCodes`); 255 stringList(item.warnings, `input.legalSnapshot.contributors[${index}].warnings`); 256 }); 257 iso(value.evaluatedAt, "input.legalSnapshot.evaluatedAt"); 258 return { value, segments, targetFrom, targetTo }; 259 } 260 261 function dataFingerprint(snapshot) { 262 return fingerprint("mwd1", { 263 schemaVersion: 1, domain: "MISSION_WEATHER_DATA", normalizerVersion: snapshot.provenance.normalizerVersion, 264 variableSetVersion: snapshot.provenance.variableSetVersion, temporalSamplingVersion: snapshot.coverage.temporal.samplingVersion, 265 sampling: { queryCoordinate: { latitude: snapshot.provenance.queryCoordinate.latitude, longitude: snapshot.provenance.queryCoordinate.longitude }, 266 providerCoordinate: snapshot.provenance.providerCoordinate, windLevelsMetersAgl: [...WIND_LEVELS], interpolation: "NONE" }, 267 units: UNITS, hours: snapshot.hours 268 }); 269 } 270 function preferenceEvaluationFingerprint(dataFp, evaluation) { 271 if (!evaluation?.configured) return null; 272 return fingerprint("mwe1", { schemaVersion: 1, domain: "MISSION_WEATHER_PREFERENCE_EVALUATION", dataFingerprint: dataFp, 273 preferencesSchemaVersion: 1, evaluatorVersion: PREF_VERSION, thresholds: evaluation.thresholds, 274 windPolicy: "EACH_AVAILABLE_LEVEL_SEPARATELY", gustPolicy: "LEVEL_10M_ONLY" }); 275 } 276 function validateWeather(value) { 277 if (value === null) return null; 278 if (bytes(value) > MAX_SNAPSHOT_BYTES) fail("INPUT_TOO_LARGE", "input.weatherSnapshot"); 279 if (!isObject(value) || value.schemaVersion !== 1) fail("UNSUPPORTED_SCHEMA", "input.weatherSnapshot.schemaVersion"); 280 if (value.engine?.version !== WEATHER_ENGINE_VERSION) fail("UNSUPPORTED_CONTRACT", "input.weatherSnapshot.engine.version"); 281 text(value.id, "input.weatherSnapshot.id"); text(value.operatorId, "input.weatherSnapshot.operatorId"); text(value.missionId, "input.weatherSnapshot.missionId"); 282 if (!isObject(value.target) || !isObject(value.provenance) || !isObject(value.coverage) || !Array.isArray(value.hours) || !isObject(value.quality) || stable(value.units) !== stable(UNITS)) fail("MALFORMED_INPUT", "input.weatherSnapshot"); 283 if (value.hours.length > MAX_WEATHER_SLOTS || value.coverage.spatial?.level !== "POINT_ONLY" || value.coverage.spatial?.samplingMethodVersion !== "REPRESENTATIVE_POINT_ONLY_V1" || value.coverage.temporal?.samplingVersion !== "INTERSECTING_UTC_HOUR_SLOTS_V1" || value.coverage.temporal?.resolutionMinutes !== 60 || 284 !["FULL_TEMPORAL_COVERAGE", "PARTIAL_TEMPORAL_COVERAGE"].includes(value.coverage.temporal?.status) || 285 !["EXACT_LEVEL_AVAILABLE", "EXACT_LEVEL_PARTIAL", "BETWEEN_SAMPLED_LEVELS", "OUTSIDE_SAMPLED_RANGE", "PROFILE_UNAVAILABLE"].includes(value.coverage.altitude?.status)) fail("UNSUPPORTED_CONTRACT", "input.weatherSnapshot.coverage"); 286 if (value.provenance.adapterVersion !== "OPEN_METEO_POINT_ADAPTER_V1" || value.provenance.normalizerVersion !== "MISSION_WEATHER_NORMALIZER_V1" || value.provenance.variableSetVersion !== "MISSION_WEATHER_POINT_V1") fail("UNSUPPORTED_CONTRACT", "input.weatherSnapshot.provenance"); 287 const targetFrom = iso(value.target.plannedAt, "input.weatherSnapshot.target.plannedAt"); 288 const targetTo = iso(value.target.plannedUntil, "input.weatherSnapshot.target.plannedUntil"); 289 if (targetTo.ms <= targetFrom.ms || !isObject(value.target.plannedAltitude) || value.target.plannedAltitude.reference !== "AGL" || 290 typeof value.target.plannedAltitude.valueMeters !== "number" || !Number.isFinite(value.target.plannedAltitude.valueMeters) || 291 value.target.representativePoint?.contractVersion !== POINT_VERSION || value.target.samplingMethodVersion !== "REPRESENTATIVE_POINT_ONLY_V1") fail("MALFORMED_INPUT", "input.weatherSnapshot.target"); 292 const expectedTargetFingerprint = fingerprint("mw1", { schemaVersion: 1, domain: "MISSION_WEATHER_TARGET", 293 geometryFingerprint: value.target.geometryFingerprint, plannedAt: targetFrom.value, plannedUntil: targetTo.value, 294 plannedAltitude: value.target.plannedAltitude, representativePointContractVersion: POINT_VERSION, 295 samplingMethodVersion: value.target.samplingMethodVersion }); 296 if (expectedTargetFingerprint !== value.target.inputFingerprint) fail("INTEGRITY_MISMATCH", "input.weatherSnapshot.target.inputFingerprint"); 297 const hours = value.hours.map((hour, index) => { 298 const path = `input.weatherSnapshot.hours[${index}]`; 299 if (!isObject(hour) || !["FULL", "PARTIAL", "NONE"].includes(hour.sourceAvailability) || !Array.isArray(hour.windLevels) || hour.windLevels.length !== 3 || !Array.isArray(hour.missingFields)) fail("MALFORMED_INPUT", path); 300 const slotFrom = iso(hour.slotFromUtc, `${path}.slotFromUtc`); const slotTo = iso(hour.slotToUtc, `${path}.slotToUtc`); 301 const overlapFrom = iso(hour.overlapFromUtc, `${path}.overlapFromUtc`); const overlapTo = iso(hour.overlapToUtc, `${path}.overlapToUtc`); 302 if (slotTo.ms - slotFrom.ms !== 3600000 || overlapFrom.ms !== Math.max(slotFrom.ms, targetFrom.ms) || 303 overlapTo.ms !== Math.min(slotTo.ms, targetTo.ms) || overlapTo.ms <= overlapFrom.ms || 304 (index && slotFrom.ms !== Date.parse(value.hours[index - 1].slotToUtc))) fail("MALFORMED_INPUT", path); 305 if (!isObject(hour.sourceTimes) || hour.sourceTimes.instantAtUtc !== hour.slotFromUtc || 306 hour.sourceTimes.precedingHourEndsAtUtc !== hour.slotToUtc) fail("MALFORMED_INPUT", `${path}.sourceTimes`); 307 for (const field of ["temperatureC", "precipitationMm", "precipitationProbabilityPct", "weatherCode", "visibilityMeters", "cloudCoverPct"]) finiteOrNull(hour[field], `${path}.${field}`);
308 hour.windLevels.forEach((level, levelIndex) => { 309 if (!isObject(level) || level.heightMetersAgl !== WIND_LEVELS[levelIndex]) fail("MALFORMED_INPUT", `${path}.windLevels[${levelIndex}]`); 310 finiteOrNull(level.speedKmh, `${path}.windLevels[${levelIndex}].speedKmh`); 311 const direction = finiteOrNull(level.directionDegrees, `${path}.windLevels[${levelIndex}].directionDegrees`); 312 if (direction !== null && (direction < 0 || direction > 360)) fail("MALFORMED_INPUT", `${path}.windLevels[${levelIndex}].directionDegrees`); 313 finiteOrNull(level.gustKmh, `${path}.windLevels[${levelIndex}].gustKmh`); 314 if (!isObject(level.fieldStatus) || level.fieldStatus.speedKmh !== (level.speedKmh === null ? "MISSING" : "AVAILABLE") || 315 level.fieldStatus.directionDegrees !== (level.directionDegrees === null ? "MISSING" : "AVAILABLE") || 316 (levelIndex === 0 && level.fieldStatus.gustKmh !== (level.gustKmh === null ? "MISSING" : "AVAILABLE")) || 317 (levelIndex > 0 && (level.gustKmh !== null || level.fieldStatus.gustKmh !== "UNSUPPORTED"))) fail("MALFORMED_INPUT", `${path}.windLevels[${levelIndex}].fieldStatus`); 318 }); 319 const expectedMissing = []; 320 for (const field of ["temperatureC", "precipitationMm", "precipitationProbabilityPct", "weatherCode", "visibilityMeters", "cloudCoverPct"]) if (hour[field] === null) expectedMissing.push(field);
321 hour.windLevels.forEach(level => { 322 if (level.speedKmh === null) expectedMissing.push(`wind.${level.heightMetersAgl}.speedKmh`); 323 if (level.directionDegrees === null) expectedMissing.push(`wind.${level.heightMetersAgl}.directionDegrees`); 324 if (level.heightMetersAgl === 10 && level.gustKmh === null) expectedMissing.push("wind.10.gustKmh"); 325 }); 326 if (stable([...hour.missingFields].sort()) !== stable(expectedMissing.sort())) fail("MALFORMED_INPUT", `${path}.missingFields`); 327 if (hour.sourceAvailability === "NONE" && 328 [hour.temperatureC, hour.precipitationMm, hour.precipitationProbabilityPct, hour.weatherCode, 329 hour.visibilityMeters, hour.cloudCoverPct, 330 ...hour.windLevels.flatMap(level => [level.speedKmh, level.directionDegrees, level.gustKmh])] 331 .some(item => item !== null)) fail("MALFORMED_INPUT", `${path}.sourceAvailability`); 332 return { ...hour, slotFromMs: slotFrom.ms, slotToMs: slotTo.ms, overlapFromMs: overlapFrom.ms, overlapToMs: overlapTo.ms }; 333 }); 334 const availableCount = hours.filter(hour => hour.sourceAvailability === "FULL").length; 335 const partialCount = hours.filter(hour => hour.sourceAvailability === "PARTIAL").length; 336 const missingCount = hours.filter(hour => hour.sourceAvailability === "NONE").length; 337 const expectedTemporalStatus = availableCount === hours.length ? "FULL_TEMPORAL_COVERAGE" : "PARTIAL_TEMPORAL_COVERAGE"; 338 const expectedFields = fieldCoverage(hours); 339 const hasProfile = hours.some(hour => hour.windLevels.some(level => level.speedKmh !== null || level.directionDegrees !== null)); 340 const exactLevels = hours.flatMap(hour => hour.windLevels).filter(level => level.heightMetersAgl === value.target.plannedAltitude.valueMeters); 341 const expectedAltitudeStatus = !hasProfile ? "PROFILE_UNAVAILABLE" : exactLevels.length 342 ? (exactLevels.every(level => level.speedKmh !== null && level.directionDegrees !== null) ? "EXACT_LEVEL_AVAILABLE" : "EXACT_LEVEL_PARTIAL") 343 : value.target.plannedAltitude.valueMeters < 10 || value.target.plannedAltitude.valueMeters > 120 ? "OUTSIDE_SAMPLED_RANGE" : "BETWEEN_SAMPLED_LEVELS"; 344 const expectedAltitude = { status: expectedAltitudeStatus, plannedAltitude: value.target.plannedAltitude, 345 sampledLevelsMetersAgl: [...WIND_LEVELS], interpolation: "NONE" }; 346 const expectedQuality = expectedTemporalStatus === "FULL_TEMPORAL_COVERAGE" && !Object.values(expectedFields).some(item => item.missing > 0) ? "COMPLETE" : "PARTIAL"; 347 if (!hours.length || hours[0].overlapFromMs !== targetFrom.ms || hours[hours.length - 1].overlapToMs !== targetTo.ms || 348 value.coverage.temporal.requestedHourCount !== hours.length || value.coverage.temporal.availableHourCount !== availableCount || 349 value.coverage.temporal.partialHourCount !== partialCount || value.coverage.temporal.missingHourCount !== missingCount || 350 value.coverage.temporal.status !== expectedTemporalStatus || stable(value.coverage.altitude) !== stable(expectedAltitude) || 351 stable(value.quality.fieldCoverage) !== stable(expectedFields) || value.quality.status !== expectedQuality) fail("MALFORMED_INPUT", "input.weatherSnapshot.hours"); 352 const recomputed = dataFingerprint(value); 353 if (recomputed !== value.dataFingerprint) fail("INTEGRITY_MISMATCH", "input.weatherSnapshot.dataFingerprint"); 354 const frozen = value.evaluation?.personalPreferences; 355 if (frozen && frozen.evaluatorVersion !== PREF_VERSION) fail("UNSUPPORTED_CONTRACT", "input.weatherSnapshot.evaluation.personalPreferences.evaluatorVersion"); 356 if (frozen && preferenceEvaluationFingerprint(value.dataFingerprint, frozen) !== frozen.inputFingerprint) fail("INTEGRITY_MISMATCH", "input.weatherSnapshot.evaluation.personalPreferences.inputFingerprint"); 357 if (frozen) { 358 const allowed = ["maxWindKmh", "maxGustKmh", "avoidPrecipitation", "minTemperatureC", "maxTemperatureC"]; 359 if (frozen.schemaVersion !== 1 || !isObject(frozen.thresholds) || Object.keys(frozen.thresholds).some(key => !allowed.includes(key)) || frozen.configured !== (Object.keys(frozen.thresholds).length > 0)) fail("MALFORMED_INPUT", "input.weatherSnapshot.evaluation.personalPreferences"); 360 const frozenPreferences = { schemaVersion: 1, weather: Object.fromEntries(allowed.map(key => [key, Object.hasOwn(frozen.thresholds, key) ? frozen.thresholds[key] : null])) }; 361 const expectedChecks = evaluatePreferences(hours, frozenPreferences).checks; 362 if (stable(frozen.checks) !== stable(expectedChecks)) fail("INTEGRITY_MISMATCH", "input.weatherSnapshot.evaluation.personalPreferences.checks"); 363 } 364 return { value, hours }; 365 } 366 367 function currentPreferenceFingerprint(preferences) { 368 return fingerprint("bwp1", { schemaVersion: 1, evaluatorVersion: PREF_VERSION, thresholds: preferences.weather }); 369 } 370 function freshness(snapshot, evaluatedMs) { 371 const retrieved = iso(snapshot.provenance.dataRetrievedAt, "input.weatherSnapshot.provenance.dataRetrievedAt"); 372 const age = evaluatedMs - retrieved.ms; 373 if (age < 0) fail("MALFORMED_INPUT", "input.weatherSnapshot.provenance.dataRetrievedAt"); 374 return { value: age <= 90 * 60000 ? "RECENT" : age <= 6 * 3600000 ? "AGED" : "STALE", retrieved }; 375 } 376 377 function lightSegments(mission) { 378 return temporalCall(() => temporalCore().buildMissionLightSegments({ 379 schemaVersion: 1, 380 interval: { fromUtc: mission.from.value, toUtc: mission.to.value }, 381 operationalGeometry: mission.geometry 382 }), "input.mission.operationalGeometry"); 383 } 384 function lightAt(segments, ms) { 385 const segment = segments.find(item => ms >= Date.parse(item.fromUtc) && ms < Date.parse(item.toUtc)); 386 return segment?.status || "UNKNOWN"; 387 } 388 389 function numeric(values) { 390 const comparable = values.filter(value => value !== null && Number.isFinite(value)); 391 return { min: comparable.length ? Math.min(...comparable) : null, max: comparable.length ? Math.max(...comparable) : null, 392 comparableSampleCount: comparable.length, missingSampleCount: values.length - comparable.length }; 393 } 394 function weatherFacts(hours) { 395 const field = key => numeric(hours.map(hour => hour[key])); 396 return { 397 temperatureC: field("temperatureC"), precipitationMm: field("precipitationMm"), 398 precipitationProbabilityPct: field("precipitationProbabilityPct"), 399 weatherCodes: sortedUnique(hours.map(hour => hour.weatherCode).filter(value => value !== null)), 400 visibilityMeters: field("visibilityMeters"), cloudCoverPct: field("cloudCoverPct"), 401 windLevels: WIND_LEVELS.map((height, index) => { 402 const levels = hours.map(hour => hour.windLevels[index]); 403 const output = { heightMeters: height, speedKmh: numeric(levels.map(level => level.speedKmh)), 404 directionDegreesObserved: sortedUnique(levels.map(level => level.directionDegrees).filter(value => value !== null), value => value === 360 ? 0 : value) }; 405 if (height === 10) output.gustKmh = numeric(levels.map(level => level.gustKmh)); 406 return output; 407 }) 408 }; 409 } 410 function coverageEntry(hours, getter) { const available = hours.filter(hour => getter(hour) !== null).length; return { expected: hours.length, available, missing: hours.length - available }; } 411 function fieldCoverage(hours) { 412 const output = {}; 413 for (const key of ["temperatureC", "precipitationMm", "precipitationProbabilityPct", "weatherCode", "visibilityMeters", "cloudCoverPct"]) output[key] = coverageEntry(hours, hour => hour[key]); 414 WIND_LEVELS.forEach((height, index) => { 415 output[`wind${height}mSpeedKmh`] = coverageEntry(hours, hour => hour.windLevels[index].speedKmh); 416 output[`wind${height}mDirectionDegrees`] = coverageEntry(hours, hour => hour.windLevels[index].directionDegrees); 417 if (height === 10) output.wind10mGustKmh = coverageEntry(hours, hour => hour.windLevels[index].gustKmh); 418 }); 419 return output; 420 } 421 function sourceAvailability(hours) { 422 if (hours.every(hour => hour.sourceAvailability === "FULL")) return "FULL"; 423 if (hours.some(hour => hour.sourceAvailability !== "NONE")) return "PARTIAL"; 424 return "NONE"; 425 } 426 function check(criterion, limitValue, values, exceeds, heightMetersAgl) { 427 const comparable = values.filter(value => value !== null); 428 const exceeded = comparable.filter(exceeds); 429 const result = { criterion, limit: limitValue, status: !comparable.length ? "UNAVAILABLE" : exceeded.length ? "EXCEEDANCE_PRESENT" : "NO_EXCEEDANCE", 430 comparableSampleCount: comparable.length, exceedanceCount: exceeded.length, missingSampleCount: values.length - comparable.length, 431 observedExtreme: comparable.length ? (criterion === "MIN_TEMPERATURE" ? Math.min(...comparable) : Math.max(...comparable)) : null }; 432 if (heightMetersAgl !== undefined) result.heightMetersAgl = heightMetersAgl; 433 return result; 434 } 435 function evaluatePreferences(hours, preferences) { 436 const p = preferences.weather; const checks = []; 437 if (p.maxWindKmh !== null) WIND_LEVELS.forEach((height, index) => checks.push(check("MAX_WIND", p.maxWindKmh, hours.map(hour => hour.windLevels[index].speedKmh), value => value > p.maxWindKmh, height))); 438 if (p.maxGustKmh !== null) checks.push(check("MAX_GUST", p.maxGustKmh, hours.map(hour => hour.windLevels[0].gustKmh), value => value > p.maxGustKmh, 10)); 439 if (p.avoidPrecipitation !== null) checks.push(check("AVOID_PRECIPITATION", p.avoidPrecipitation, hours.map(hour => hour.precipitationMm), value => p.avoidPrecipitation && value > 0)); 440 if (p.minTemperatureC !== null) checks.push(check("MIN_TEMPERATURE", p.minTemperatureC, hours.map(hour => hour.temperatureC), value => value < p.minTemperatureC)); 441 if (p.maxTemperatureC !== null) checks.push(check("MAX_TEMPERATURE", p.maxTemperatureC, hours.map(hour => hour.temperatureC), value => value > p.maxTemperatureC)); 442 const status = !checks.length ? "NOT_CONFIGURED" : checks.some(item => item.status === "EXCEEDANCE_PRESENT") ? "EXCEEDANCE_PRESENT" : checks.some(item => item.status === "UNAVAILABLE") ? "UNAVAILABLE" : "NO_EXCEEDANCE"; 443 return { status, checks }; 444 } 445 function evaluateDrone(hours, drone) { 446 if (!drone) return { status: "NOT_CONFIGURED", comparisonCompleteness: "NONE", observedMaxWindKmh: null, declaredLimitKmh: null, basis: null, catalogState: "NOT_CONFIGURED" }; 447 const values = hours.flatMap(hour => hour.windLevels.map(level => level.speedKmh)).filter(value => value !== null); 448 const expected = hours.length * 3; const completeness = !values.length ? "NONE" : values.length === expected ? "FULL" : "PARTIAL"; 449 const observed = values.length ? Math.max(...values) : null; 450 const resistance = drone.windResistance; const declared = resistance ? resistance.valueMs * 3.6 : null; 451 const matchUnavailable = ["NOT_FOUND", "AMBIGUOUS"].includes(drone.matchStatus); 452 const status = matchUnavailable || declared === null || observed === null ? "UNKNOWN" : observed > declared ? "DECLARED_WIND_LIMIT_EXCEEDED" : "WITHIN_AVAILABLE_WIND_DATA"; 453 return { status, comparisonCompleteness: completeness, observedMaxWindKmh: observed, declaredLimitKmh: declared, 454 basis: resistance?.basis || null, catalogState: drone.catalog.stale ? "STALE" : "CURRENT" }; 455 } 456 457 function legalEvaluation(segment, altitude) { 458 const status = segment.status; const required = segment.authorization.required === true || status === "AUTH_REQUIRED"; 459 const max = segment.maxAltitudeMetersAgl; const relation = max === null ? "UNKNOWN" : altitude < max ? "BELOW_LIMIT" : altitude === max ? "AT_LIMIT" : "ABOVE_LIMIT"; 460 let placement; 461 if (status === "UNAVAILABLE") placement = "NOT_CANDIDATE"; 462 else if (status === "UNKNOWN") placement = "UNKNOWN"; 463 else if (required) placement = "REQUIRES_ATTENTION"; 464 else if (status === "AVAILABLE_WITH_LIMIT") placement = max === null ? "UNKNOWN" : altitude <= max ? "LIMITED" : "NOT_CANDIDATE"; 465 else placement = "CANDIDATE"; 466 const refs = items => items.map(item => ({ type: item.type || null, version: item.version == null ? null : String(item.version), reasonCode: item.reasonCode || null })).filter(item => item.type || item.reasonCode).sort((a, b) => stable(a).localeCompare(stable(b))); 467 return { placement, output: { sourceStatus: status, maxAltitudeMetersAgl: max, plannedAltitudeRelation: relation, 468 authorization: { required, resolutionStatus: segment.authorization.resolutionStatus, 469 procedureRefs: refs(segment.authorization.procedures), additionalRequirementRefs: refs(segment.authorization.additionalRequirements), 470 blockingReasonCodes: sortedUnique(segment.authorization.blockingReasons.map(item =>
470 item.reasonCode || item.type).filter(Boolean)) }, 471 contributorIds: sortedUnique(segment.contributorIds), reasonCodes: sortedUnique(segment.reasonCodes) } }; 472 } 473 function temporalCoverage(hours) { const state = sourceAvailability(hours); return state === "FULL" ? "FULL" : state === "NONE" ? "NONE" : "PARTIAL"; } 474 function fieldQuality(fields) { return Object.values(fields).some(item => item.missing > 0) ? "PARTIAL" : "COMPLETE"; } 475 function reasonsFor({ legal, preferences, drone, droneProjection, temporal, quality, fresh, light }) { 476 const reasons = ["POINT_ONLY", light]; 477 if (legal.sourceStatus === "UNAVAILABLE") reasons.push("LEGAL_UNAVAILABLE"); 478 if (legal.sourceStatus === "UNKNOWN") reasons.push("LEGAL_UNKNOWN"); 479 if (legal.plannedAltitudeRelation === "ABOVE_LIMIT") reasons.push("LEGAL_LIMIT_BELOW_PLANNED_ALTITUDE"); 480 if (legal.sourceStatus === "AVAILABLE_WITH_LIMIT" && legal.maxAltitudeMetersAgl === null) reasons.push("LEGAL_LIMIT_UNKNOWN"); 481 if (legal.authorization.required) reasons.push("AUTHORIZATION_REQUIRED"); 482 preferences.checks.filter(item => item.status === "EXCEEDANCE_PRESENT").forEach(item => reasons.push({ MAX_WIND: "PREFERENCE_WIND_EXCEEDED", MAX_GUST: "PREFERENCE_GUST_EXCEEDED", AVOID_PRECIPITATION: "PREFERENCE_PRECIPITATION_EXCEEDED", MIN_TEMPERATURE: "PREFERENCE_MIN_TEMPERATURE_EXCEEDED", MAX_TEMPERATURE: "PREFERENCE_MAX_TEMPERATURE_EXCEEDED" }[item.criterion])); 483 if (preferences.status === "UNAVAILABLE") reasons.push("PREFERENCE_DATA_UNAVAILABLE"); 484 if (drone.status === "NOT_CONFIGURED") reasons.push("DRONE_NOT_SELECTED"); 485 if (drone.status === "UNKNOWN") reasons.push(["NOT_FOUND", "AMBIGUOUS"].includes(droneProjection?.matchStatus) ? "DRONE_MATCH_UNAVAILABLE" : "DRONE_WIND_LIMIT_UNAVAILABLE"); 486 if (drone.status === "DECLARED_WIND_LIMIT_EXCEEDED") reasons.push("DRONE_WIND_LIMIT_EXCEEDED"); 487 if (drone.catalogState === "STALE") reasons.push("DRONE_CATALOG_STALE"); 488 if (temporal !== "FULL") reasons.push("WEATHER_PARTIAL"); 489 if (quality !== "COMPLETE") reasons.push("WEATHER_FIELDS_MISSING"); 490 if (fresh === "STALE") reasons.push("WEATHER_STALE"); 491 if (light === "UNKNOWN") reasons.push("LIGHT_UNKNOWN"); 492 return orderedReasons(reasons); 493 } 494 function limitationCount(reasons) { return reasons.filter(reason => /LIMIT|EXCEEDED|PARTIAL|STALE|AUTHORIZATION/.test(reason)).length; } 495 function unknownCount({ placement, preferences, drone, temporal, quality, light }) { return [placement, preferences, drone, temporal, quality, light].filter(value => ["UNKNOWN", "UNAVAILABLE", "NONE"].includes(value)).length; } 496 function disposition(placement, preferences, drone, temporal, quality, freshnessValue, light, altitudeSamplingValue) { 497 if (placement === "NOT_CANDIDATE") return "EXCLUDED"; 498 if (["REQUIRES_ATTENTION", "UNKNOWN"].includes(placement) || ["EXCEEDANCE_PRESENT", "UNAVAILABLE"].includes(preferences) || ["DECLARED_WIND_LIMIT_EXCEEDED", "UNKNOWN"].includes(drone) || temporal !== "FULL" || quality !== "COMPLETE" || freshnessValue === "STALE" || light === "UNKNOWN" || altitudeSamplingValue !== "EXACT_LEVEL_AVAILABLE") return "DEPRIORITIZED"; 499 return "INCLUDED"; 500 } 501 502 function rankIndex(axis, value) { const index = RANKS[axis].indexOf(value); return index < 0 ? Number.MAX_SAFE_INTEGER : index; } 503 function compareWindows(a, b) { 504 for (const axis of Object.keys(RANKS)) { const diff = rankIndex(axis, a.rank[axis]) - rankIndex(axis, b.rank[axis]); if (diff) return diff; } 505 if (a.rank.limitationCount !== b.rank.limitationCount) return a.rank.limitationCount - b.rank.limitationCount; 506 if (a.rank.unknownCount !== b.rank.unknownCount) return a.rank.unknownCount - b.rank.unknownCount; 507 if (a.durationMinutes !== b.durationMinutes) return b.durationMinutes - a.durationMinutes; 508 if (a.fromUtc !== b.fromUtc) return a.fromUtc.localeCompare(b.fromUtc); 509 if (a.toUtc !== b.toUtc) return a.toUtc.localeCompare(b.toUtc); 510 return a._semanticFingerprint.localeCompare(b._semanticFingerprint); 511 } 512 function mergeSignature(window) { 513 return stable({ candidateDisposition: window.candidateDisposition, placementBand: window.placementBand, 514 legal: window.legal, preferences: { status: window.preferences.status, checks: window.preferences.checks.map(({ comparableSampleCount, exceedanceCount, missingSampleCount, observedExtreme, ...semantic }) => semantic) }, 515 droneTechnical: { status: window.droneTechnical.status, comparisonCompleteness: window.droneTechnical.comparisonCompleteness, 516 declaredLimitKmh: window.droneTechnical.declaredLimitKmh, basis: window.droneTechnical.basis, catalogState: window.droneTechnical.catalogState }, 517 light: window.light, coverage: window.coverage, reasonCodes: window.reasonCodes }); 518 } 519 function buildWindow(fromMs, toMs, segment, hours, context) { 520 const legal = legalEvaluation(segment, context.mission.altitude); const prefs = evaluatePreferences(hours, context.preferences); 521 const drone = evaluateDrone(hours, context.drone); const facts = weatherFacts(hours); const fields = fieldCoverage(hours); 522 const temporal = temporalCoverage(hours); const quality = fieldQuality(fields); const light = lightAt(context.light, fromMs); 523 const reasons = reasonsFor({ legal: legal.output, preferences: prefs, drone, droneProjection: context.drone, temporal, quality, fresh: context.freshness, light });
524 const candidateDisposition = disposition(legal.placement, prefs.status, drone.status, temporal, quality, context.freshness, light, context.altitudeSampling); 525 const output = { fromUtc: new Date(fromMs).toISOString(), toUtc: new Date(toMs).toISOString(), durationMinutes: (toMs - fromMs) / 60000, 526 candidateDisposition, placementBand: legal.placement, 527 rank: { placement: legal.placement, preferences: prefs.status, droneTechnical: drone.status, 528 legalCompleteness: context.legalCompleteness, weatherTemporalCoverage: temporal, weatherFieldQuality: quality, 529 weatherFreshness: context.freshness, droneCatalog: drone.catalogState, limitationCount: limitationCount(reasons), 530 unknownCount: unknownCount({ placement: legal.placement, preferences: prefs.status, drone: drone.status, temporal, quality, light }) }, 531 legal: legal.output, weather: { facts, sourceAvailability: sourceAvailability(hours), fieldCoverage: fields }, 532 preferences: prefs, droneTechnical: drone, light: { status: light }, 533 coverage: { spatial: "POINT_ONLY", temporal, legalSourceCompleteness: context.legalCompleteness, 534 weatherQuality: quality, weatherFreshness: context.freshness, altitudeSampling: context.altitudeSampling }, reasonCodes: reasons }; 535 output._hours = hours; output._segment = segment; output._semanticFingerprint = fingerprint("bww1", { fromUtc: output.fromUtc, toUtc: output.toUtc, signature: mergeSignature(output) }); 536 return output; 537 } 538 function stripInternal(window) { const { _hours, _segment, _semanticFingerprint, ...output } = window; return output; } 539 540 function emptyOutput(context, legalInfo, weatherInfo, reasons) { 541 const output = baseOutput(context, legalInfo, weatherInfo, { level: "NONE", reasonCodes: orderedReasons(reasons) }, { atomicIntervalCount: 0, windowCount: 0, includedCount: 0, deprioritizedCount: 0, excludedCount: 0 }, []); 542 output.calculationFingerprint = calculationFingerprint(context, legalInfo, weatherInfo); 543 enforceOutput(output); 544 return output; 545 } 546 function inputLegal(value, currentness) { return value ? { snapshotId: value.id, inputFingerprint: value.mission.inputFingerprint, evaluatedAt: value.evaluatedAt, 547 currentness, sourceCompleteness: value.quality.sourceCompleteness, engineVersion: value.engine.version } : null; } 548 function inputWeather(value, currentness, fresh) { return value ? { snapshotId: value.id, targetFingerprint: value.target.inputFingerprint, 549 dataFingerprint: value.dataFingerprint, dataRetrievedAt: value.provenance.dataRetrievedAt, currentness, freshness: fresh, 550 temporalCoverage: value.coverage.temporal.status === "FULL_TEMPORAL_COVERAGE" ? "FULL" : "PARTIAL", quality: value.quality.status, 551 engineVersion: value.engine.version } : null; } 552 function altitudeSampling(snapshot) { return snapshot?.coverage?.altitude?.status || "PROFILE_UNAVAILABLE"; } 553 function baseOutput(context, legalInfo, weatherInfo, availability, summary, windows) { 554 const drone = context.drone; 555 return { schemaVersion: 1, engine: { name: "DSC", version: ENGINE_VERSION }, evaluatedAtUtc: context.evaluated.value, 556 mission: { operatorId: context.mission.operatorId, missionId: context.mission.id, plannedAt: context.mission.from.value, 557 plannedUntil: context.mission.to.value, plannedAltitude: { valueMeters: context.mission.altitude, reference: "AGL" }, 558 geometryFingerprint: context.mission.geometryFingerprint, representativePointContractVersion: POINT_VERSION }, 559 inputs: { legal: legalInfo, weather: weatherInfo, 560 preferences: { schemaVersion: 1, evaluatorVersion: PREF_VERSION, inputFingerprint: context.preferenceFingerprint, 561 configured: Object.values(context.preferences.weather).some(value => value !== null) }, 562 droneTechnical: drone ? { droneId: drone.droneId, contractVersion: drone.contractVersion, matchStatus: drone.matchStatus, 563 resistanceBasis: drone.windResistance?.basis || null, catalogVersion: drone.catalog.catalogVersion, 564 catalogSha256: drone.catalog.sha256, catalogState: drone.catalog.stale ? "STALE" : "CURRENT" } : null, 565 light: { contractVersion: LIGHT_VERSION, source: "SUN_CALC", sourceVersion: "1.9.0", timezone: "Europe/Rome" } }, 566 coverage: { spatial: "POINT_ONLY", representativeness: "LIMITED", temporal: weatherInfo?.temporalCoverage || "NONE", 567 altitudeSampling: altitudeSampling(context.weather?.value), weatherResolutionMinutes: 60, interpolation: "NONE" }, 568 availability, summary, calculationFingerprint: null, windows }; 569 } 570 function calculationFingerprint(context, legalInfo, weatherInfo) { 571 const contributors = legalInfo && context.legal?.value?.contributors?.map(item => ({ id: item.id, sourceType: item.sourceType || null, 572 temporalPolicy: item.temporalPolicy || null, operationalRelevance: item.operationalRelevance || null, 573 maxAltitudeMetersAgl: Number.isFinite(item.maxAltitudeMetersAgl) ? item.maxAltitudeMetersAgl : null, 574 effects: sortedUnique(item.effects), reasonCodes: sortedUnique(item.reasonCodes), warnings: sortedUnique(item.warnings) })) 575 .sort((a, b) => a.id.localeCompare(b.id)) || []; 576 return fingerprint("bw1", { schemaVersion: 1, engineVersion: ENGINE_VERSION, evaluatedAtUtc: context.evaluated.value, 577 mission: { operatorId: context.mission.operatorId, missionId: context.mission.id, legalTargetFingerprint: context.legalTarget, 578 weatherTargetFingerprint: context.weatherTarget }, legal: legalInfo && context.legal?.value ? { inputFingerprint: legalInfo.inputFingerprint, 579 engineVersion: legalInfo.engineVersion, 580 segments: context.legal.value.segments.map(segment => ({ from: segment.from, to: segment.to, 581 legal: legalEvaluation(segment, context.mission.altitude).output })), 582 contributors, sourceCompleteness: context.legal.value.quality.sourceCompleteness } : null, 583 weatherDataFingerprint: weatherInfo?.dataFingerprint || null, preferenceFingerprint: context.preferenceFingerprint, 584 droneTechnical: context.drone, light: { contractVersion: LIGHT_VERSION, sourceVersion: "1.9.0", timezone: "Europe/Rome" } }); 585 } 586 function enforceOutput(output) { if (bytes(output) > MAX_OUTPUT_BYTES) limit("BEST_WINDOWS_OUTPUT_TOO_LARGE", "output"); } 587 588 function buildMissionBestWindowsAtomicTimeline(context, legal, weather) { 589 const { mission } = context; 590 const timeline = temporalCall(() => temporalCore().buildMissionAtomicTimeline({ 591 schemaVersion: 1, 592 interval: { fromUtc: mission.from.value, toUtc: mission.to.value }, 593 legalSegments: legal.segments.map(segment => ({ fromUtc: segment.from, toUtc: segment.to })), 594 weatherSlots: weather.hours.map(hour => ({ fromUtc: hour.overlapFromUtc, toUtc: hour.overlapToUtc })), 595 lightSegments: context.light 596 }), "input.snapshots.timeline"); 597 return timeline.atomicIntervals.map(atomic => buildWindow( 598 Date.parse(atomic.fromUtc), 599 Date.parse(atomic.toUtc), 600 legal.segments[atomic.legalSegmentIndex], 601 [weather.hours[atomic.weatherSlotIndex]], 602 context 603 )); 604 } 605 606 function computeMissionBestWindows(input) { 607 assertPlain(input); 608 if (bytes(input) > MAX_INPUT_BYTES) fail("INPUT_TOO_LARGE", "input"); 609 exactKeys(input, ["schemaVersion", "evaluatedAtUtc", "mission", "legalSnapshot", "weatherSnapshot", "preferences", "droneTechnical"], "input"); 610 if (input.schemaVersion !== 1) fail("UNSUPPORTED_SCHEMA", "input.schemaVersion"); 611 const evaluated = iso(input.evaluatedAtUtc, "input.evaluatedAtUtc"); 612 const mission = validateMission(input.mission); const preferences = validatePreferences(input.preferences);
612 const drone = validateDrone(input.droneTechnical); 613 const legal = validateLegal(input.legalSnapshot); const weather = validateWeather(input.weatherSnapshot); 614 const context = { evaluated, mission, preferences, drone, legal, weather, preferenceFingerprint: currentPreferenceFingerprint(preferences), 615 legalTarget: legalFingerprint(mission), weatherTarget: weatherFingerprint(mission) }; 616 if ((legal && (legal.value.operatorId !== mission.operatorId || legal.value.missionId !== mission.id)) || (weather && (weather.value.operatorId !== mission.operatorId || weather.value.missionId !== mission.id))) { 617 context.light = lightSegments(mission); 618 return emptyOutput(context, null, null, ["INPUT_SCOPE_MISMATCH"]); 619 } 620 const legalCurrent = !!legal && legal.value.mission.inputFingerprint === context.legalTarget; 621 const weatherCurrent = !!weather && weather.value.target.inputFingerprint === context.weatherTarget; 622 const fresh = weather ? freshness(weather.value, evaluated.ms).value : null; 623 const legalInfo = inputLegal(legal?.value, legalCurrent ? "CURRENT" : "NOT_CURRENT"); 624 const weatherInfo = inputWeather(weather?.value, weatherCurrent ? "CURRENT" : "NOT_CURRENT", fresh); 625 context.light = lightSegments(mission); 626 if (!legalCurrent || !weatherCurrent) { 627 const reasons = []; 628 if (!legal) reasons.push("LEGAL_SNAPSHOT_MISSING"); else if (!legalCurrent) reasons.push("LEGAL_SNAPSHOT_NOT_CURRENT"); 629 if (!weather) reasons.push("WEATHER_SNAPSHOT_MISSING"); else if (!weatherCurrent) reasons.push("WEATHER_SNAPSHOT_NOT_CURRENT"); 630 return emptyOutput(context, legalInfo, weatherInfo, reasons); 631 } 632 context.freshness = fresh; context.legalCompleteness = legal.value.quality.sourceCompleteness; 633 context.altitudeSampling = altitudeSampling(weather.value); 634 const atomics = buildMissionBestWindowsAtomicTimeline(context, legal, weather); 635 const groups = []; 636 for (const window of atomics) { 637 const signature = mergeSignature(window); const previous = groups[groups.length - 1]; 638 if (previous && previous.toMs === Date.parse(window.fromUtc) && previous.signature === signature) { 639 previous.toMs = Date.parse(window.toUtc); 640 window._hours.forEach(hour => previous.hours.set(hour.slotFromUtc, hour)); 641 } else { 642 groups.push({ fromMs: Date.parse(window.fromUtc), toMs: Date.parse(window.toUtc), signature, 643 segment: window._segment, hours: new Map(window._hours.map(hour => [hour.slotFromUtc, hour])) }); 644 } 645 } 646 const merged = groups.map(group => buildWindow(group.fromMs, group.toMs, group.segment, 647 [...group.hours.values()].sort((a, b) => a.slotFromMs - b.slotFromMs), context)); 648 merged.sort(compareWindows); 649 if (merged.length > MAX_WINDOWS) limit("BEST_WINDOWS_TOO_MANY", "output.windows"); 650 const windows = merged.map(stripInternal); 651 const summary = { atomicIntervalCount: atomics.length, windowCount: windows.length, 652 includedCount: windows.filter(item => item.candidateDisposition === "INCLUDED").length, 653 deprioritizedCount: windows.filter(item => item.candidateDisposition === "DEPRIORITIZED").length, 654 excludedCount: windows.filter(item => item.candidateDisposition === "EXCLUDED").length }; 655 const partial = context.altitudeSampling !== "EXACT_LEVEL_AVAILABLE" || windows.some(item => item.placementBand === "UNKNOWN" || 656 item.preferences.status === "UNAVAILABLE" || item.coverage.temporal !== "FULL" || 657 item.coverage.legalSourceCompleteness !== "COMPLETE" || item.coverage.weatherQuality !== "COMPLETE" || 658 item.light.status === "UNKNOWN" || (drone && ["UNKNOWN", "PARTIAL", "NONE"].includes( 659 item.droneTechnical.status === "UNKNOWN" ? "UNKNOWN" : item.droneTechnical.comparisonCompleteness))); 660 const availability = { level: partial ? "PARTIAL" : "FULL", reasonCodes: orderedReasons(windows.flatMap(item => item.reasonCodes)) }; 661 const output = baseOutput(context, legalInfo, weatherInfo, availability, summary, windows); 662 output.calculationFingerprint = calculationFingerprint(context, legalInfo, weatherInfo); 663 enforceOutput(output); 664 return output; 665 } 666 667 function prepareMissionTimelineInput(input) { 668 assertPlain(input); 669 if (bytes(input) > MAX_INPUT_BYTES) fail("INPUT_TOO_LARGE", "input"); 670 exactKeys(input, ["schemaVersion", "evaluatedAtUtc", "mission", "legalSnapshot", "weatherSnapshot", "preferences", "droneTechnical"], "input"); 671 if (input.schemaVersion !== 1) fail("UNSUPPORTED_SCHEMA", "input.schemaVersion"); 672 const evaluated = iso(input.evaluatedAtUtc, "input.evaluatedAtUtc"); 673 const mission = validateMission(input.mission); const preferences = validatePreferences(input.preferences);
673 const drone = validateDrone(input.droneTechnical); 674 let legal = validateLegal(input.legalSnapshot); let weather = validateWeather(input.weatherSnapshot); 675 const legalTarget = legalFingerprint(mission); const weatherTarget = weatherFingerprint(mission); 676 if (legal && (legal.value.operatorId !== mission.operatorId || legal.value.missionId !== mission.id)) legal = null; 677 if (weather && (weather.value.operatorId !== mission.operatorId || weather.value.missionId !== mission.id)) weather = null; 678 const legalCurrent = !!legal && legal.value.mission.inputFingerprint === legalTarget; 679 const weatherCurrent = !!weather && weather.value.target.inputFingerprint === weatherTarget; 680 const fresh = weather ? freshness(weather.value, evaluated.ms) : null; 681 return { 682 evaluated, mission, preferences, drone, legal, weather, legalTarget, weatherTarget, 683 legalCurrent, weatherCurrent, fresh, 684 light: lightSegments(mission), 685 legalCompleteness: legal?.value?.quality?.sourceCompleteness || null, 686 altitudeSampling: altitudeSampling(weather?.value) 687 }; 688 } 689 690 const timelineSupport = Object.freeze({ 691 prepareMissionTimelineInput, 692 stable, 693 orderedReasons, 694 legalEvaluation, 695 evaluatePreferences, 696 evaluateDrone, 697 weatherFacts, 698 fieldCoverage, 699 fieldQuality, 700 sourceAvailability, 701 temporalCoverage 702 }); 703 704 return Object.freeze({ computeMissionBestWindows, MissionBestWindowsInputError, MissionBestWindowsLimitError, timelineSupport }); 705});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.