1+ package com.kylecorry.bell.infrastructure.alerts.space_weather
2+
3+ import android.content.Context
4+ import com.kylecorry.bell.domain.Alert
5+ import com.kylecorry.bell.domain.Category
6+ import com.kylecorry.bell.domain.Certainty
7+ import com.kylecorry.bell.domain.Severity
8+ import com.kylecorry.bell.domain.Urgency
9+ import com.kylecorry.bell.infrastructure.alerts.AlertLoader
10+ import com.kylecorry.bell.infrastructure.alerts.AlertSource
11+ import com.kylecorry.bell.infrastructure.alerts.FileType
12+ import com.kylecorry.bell.infrastructure.parsers.DateTimeParser
13+ import com.kylecorry.bell.infrastructure.parsers.selectors.Selector.Companion.text
14+ import com.kylecorry.bell.infrastructure.parsers.selectors.select
15+ import com.kylecorry.bell.ui.FormatService
16+ import com.kylecorry.luna.text.toFloatCompat
17+ import com.kylecorry.sol.math.SolMath.roundPlaces
18+ import java.net.URLEncoder
19+ import java.time.Duration
20+ import java.time.Instant
21+ import java.time.ZoneId
22+ import java.time.ZonedDateTime
23+
24+ class SentryAsteroidAlertSource (context : Context ) : AlertSource {
25+
26+ private val loader = AlertLoader (context)
27+ private val formatter = FormatService .getInstance(context)
28+
29+ private val torinoScaleDescription = mapOf (
30+ 3 to " A close encounter, meriting attention by astronomers. Current calculations give a 1% or greater chance of collision capable of localized destruction. Most likely, new telescopic observations will lead to re-assignment to Level 0." ,
31+ 4 to " A close encounter, meriting attention by astronomers. Current calculations give a 1% or greater chance of collision capable of regional devastation. Most likely, new telescopic observations will lead to re-assignment to Level 0." ,
32+ 5 to " A close encounter posing a serious, but still uncertain threat of regional devastation. Critical attention by astronomers is needed to determine conclusively whether or not a collision will occur. Governmental contingency planning may be warranted." ,
33+ 6 to " A close encounter by a large object posing a serious but still uncertain threat of a global catastrophe. Critical attention by astronomers is needed to determine conclusively whether or not a collision will occur. Governmental contingency planning may be warranted." ,
34+ 7 to " A very close encounter by a large object, which if occurring over the next century, poses an unprecedented but still uncertain threat of a global catastrophe. For such a threat, international contingency planning is warranted, especially to determine urgently and conclusively whether or not a collision will occur." ,
35+ 8 to " A collision is certain, capable of causing localized destruction for an impact over land or possibly a tsunami if close offshore." ,
36+ 9 to " A collision is certain, capable of causing unprecedented regional devastation for a land impact or the threat of a major tsunami for an ocean impact." ,
37+ 20 to " A collision is certain, capable of causing global climatic catastrophe that may threaten the future of civilization as we know it, whether impacting land or ocean."
38+ )
39+
40+ private val ignoreLowLevels = true
41+
42+ override suspend fun load (): List <Alert > {
43+ val rawAlerts = loader.load(
44+ FileType .JSON ,
45+ " https://ssd-api.jpl.nasa.gov/sentry.api?ps-min=-4&ip-min=1e-6" ,
46+ " $.data" ,
47+ mapOf (
48+ " id" to text(" id" ),
49+ " observed" to text(" last_obs" ),
50+ " name" to text(" des" ),
51+ " range" to text(" range" ),
52+ " level" to text(" ts_max" ),
53+ " velocity" to text(" v_inf" ),
54+ " diameter" to text(" diameter" ),
55+ " probability" to text(" ip" )
56+ ),
57+ mitigate304 = false
58+ )
59+
60+ return rawAlerts.mapNotNull {
61+ val id = it[" id" ] ? : return @mapNotNull null
62+ val name = it[" name" ] ? : return @mapNotNull null
63+ val observed =
64+ DateTimeParser .parseInstant(
65+ it[" observed" ] ? : return @mapNotNull null ,
66+ ZoneId .of(" UTC" )
67+ )
68+ ? : return @mapNotNull null
69+ val range = it[" range" ]?.split(" -" ) ? : return @mapNotNull null
70+ val level = it[" level" ]?.toIntOrNull() ? : return @mapNotNull null
71+ val velocity = it[" velocity" ]?.toFloatCompat()?.roundPlaces(2 )
72+ val diameter = it[" diameter" ]?.toFloatCompat()?.roundPlaces(4 )
73+ val probability = it[" probability" ]?.toFloatCompat()?.roundPlaces(4 )?.times(100 )
74+
75+ // Ignore very low level threats
76+ if (ignoreLowLevels && level <= 2 ) {
77+ return @mapNotNull null
78+ }
79+
80+ val effectiveYear = range.firstOrNull()?.toIntOrNull() ? : return @mapNotNull null
81+ val expirationYear = range.lastOrNull()?.toIntOrNull() ? : return @mapNotNull null
82+
83+ val timeUntilImpact = effectiveYear - ZonedDateTime .now(ZoneId .of(" UTC" )).year
84+
85+ // If over a decade away and under level 6, ignore
86+ if (ignoreLowLevels && timeUntilImpact > 10 && level < 6 ) {
87+ return @mapNotNull null
88+ }
89+
90+ // If over 3 decades away and level 6, ignore
91+ if (ignoreLowLevels && timeUntilImpact > 30 && level == 6 ) {
92+ return @mapNotNull null
93+ }
94+
95+ val severity = when (level) {
96+ in 3 .. 4 -> Severity .Moderate
97+ in 5 .. 8 -> Severity .Severe
98+ in 9 .. 10 -> Severity .Extreme
99+ else -> Severity .Minor
100+ }
101+
102+ val certainty = when (level) {
103+ in 5 .. 7 -> Certainty .Possible
104+ in 8 .. 10 -> Certainty .Likely
105+ else -> Certainty .Unlikely
106+ }
107+
108+ Alert (
109+ id = 0 ,
110+ identifier = id,
111+ sender = " NASA" ,
112+ sent = observed,
113+ source = getUUID(),
114+ category = Category .Other ,
115+ event = " Potential Asteroid Impact (between $effectiveYear and $expirationYear )" ,
116+ urgency = Urgency .Future ,
117+ severity = severity,
118+ certainty = certainty,
119+ link = " https://ssd-api.jpl.nasa.gov/sentry.api?des=${
120+ URLEncoder .encode(
121+ name,
122+ " UTF-8"
123+ )
124+ } " ,
125+ description = torinoScaleDescription[level],
126+ effective = ZonedDateTime .of(effectiveYear, 1 , 1 , 0 , 0 , 0 , 0 , ZoneId .of(" UTC" ))
127+ .toInstant(),
128+ expires = ZonedDateTime .of(
129+ expirationYear,
130+ 12 ,
131+ 31 ,
132+ 23 ,
133+ 59 ,
134+ 59 ,
135+ 9999 ,
136+ ZoneId .of(" UTC" )
137+ ).toInstant(),
138+ parameters = mapOf (
139+ " Name" to name,
140+ " Diameter" to diameter.toString() + " km" ,
141+ " Velocity" to velocity.toString() + " km/s" ,
142+ " Probability" to probability.toString() + " %" ,
143+ " Torino scale" to level.toString(),
144+ " Impact range" to " $effectiveYear to $expirationYear "
145+ ),
146+ isDownloadRequired = true ,
147+ redownloadIntervalDays = if (timeUntilImpact < 1 ) 1 else 15
148+ )
149+ }
150+ }
151+
152+ override fun getUUID (): String {
153+ return " 12f7c57c-a703-4c2a-aad3-617ee953f2af"
154+ }
155+
156+ override fun updateFromFullText (alert : Alert , fullText : String ): Alert {
157+ val impacts = select<Any >(fullText, " data" ).mapNotNull {
158+ val date =
159+ select(it, text(" date" ))?.let { parseFractionalDate(it) } ? : return @mapNotNull null
160+ val probability = select(it, text(" ip" ))?.toFloatCompat()?.times(100 )?.roundPlaces(4 )
161+ ? : return @mapNotNull null
162+ date to probability
163+ }.filter { it.first.isAfter(Instant .now()) }.sortedBy { it.first }
164+
165+ val pdate = select(fullText, text(" summary.pdate" ))?.let {
166+ DateTimeParser .parseInstant(
167+ it.replace(" " , " T" ) + " Z" ,
168+ ZoneId .of(" UTC" )
169+ )
170+ }
171+
172+ return alert.copy(
173+ sent = pdate ? : alert.sent,
174+ link = " https://cneos.jpl.nasa.gov/sentry/details.html#?des=${
175+ URLEncoder .encode(
176+ alert.parameters?.get(
177+ " Name"
178+ ) ? : " " , " UTF-8"
179+ )
180+ } " ,
181+ parameters = alert.parameters?.plus(
182+ " Potential impacts" to " \n " + impacts.joinToString(" \n " ) {
183+ val pct = if (it.second < 1 ) {
184+ " < 1%"
185+ } else {
186+ " ${it.second} %"
187+ }
188+ " - ${
189+ formatter.formatDateTime(
190+ it.first,
191+ includeWeekDay = false ,
192+ abbreviateMonth = true
193+ )
194+ } : $pct "
195+ }
196+ ),
197+ )
198+ }
199+
200+ private fun parseFractionalDate (date : String ): Instant ? {
201+ val parts = date.split(" ." )
202+ if (parts.size != 2 ) {
203+ return DateTimeParser .parseInstant(date, ZoneId .of(" UTC" ))
204+ }
205+
206+ val parsed = DateTimeParser .parseInstant(parts[0 ], ZoneId .of(" UTC" )) ? : return null
207+ val percent = (" 0." + parts[1 ]).toFloatCompat() ? : return null
208+ return parsed.plus(percentOfDayToDuration(percent))
209+ }
210+
211+ private fun percentOfDayToDuration (percent : Float ): Duration {
212+ val hours = (percent * 24 ).toInt()
213+ val minutes = ((percent * 24 - hours) * 60 ).toInt()
214+ val seconds = ((percent * 24 - hours - minutes / 60f ) * 3600 ).toInt()
215+ return Duration .ofHours(hours.toLong()).plusMinutes(minutes.toLong())
216+ .plusSeconds(seconds.toLong())
217+ }
218+ }
0 commit comments