Line data Source code
1 : module FIRMS
2 :
3 : using ..WildfireData
4 : using HTTP
5 : using CSV
6 : using DataFrames
7 : using Dates
8 :
9 :
10 : #-----------------------------------------------------------------------------# Data Directory
11 5 : dir() = WildfireData.dir("FIRMS")
12 :
13 : #-----------------------------------------------------------------------------# Constants
14 :
15 : const API_BASE = "https://firms.modaps.eosdis.nasa.gov/api"
16 :
17 : # Available data sources
18 : const SOURCES = Dict{Symbol, NamedTuple{(:name, :description, :start_date, :type), Tuple{String, String, String, Symbol}}}(
19 : :MODIS_NRT => (
20 : name = "MODIS_NRT",
21 : description = "MODIS Collection 6.1 Near Real-Time (Aqua/Terra satellites)",
22 : start_date = "2000-11-01",
23 : type = :NRT
24 : ),
25 : :MODIS_SP => (
26 : name = "MODIS_SP",
27 : description = "MODIS Collection 6.1 Standard Processing (science quality)",
28 : start_date = "2000-11-01",
29 : type = :SP
30 : ),
31 : :VIIRS_SNPP_NRT => (
32 : name = "VIIRS_SNPP_NRT",
33 : description = "VIIRS 375m S-NPP Near Real-Time",
34 : start_date = "2012-01-20",
35 : type = :NRT
36 : ),
37 : :VIIRS_SNPP_SP => (
38 : name = "VIIRS_SNPP_SP",
39 : description = "VIIRS 375m S-NPP Standard Processing",
40 : start_date = "2012-01-20",
41 : type = :SP
42 : ),
43 : :VIIRS_NOAA20_NRT => (
44 : name = "VIIRS_NOAA20_NRT",
45 : description = "VIIRS 375m NOAA-20 Near Real-Time",
46 : start_date = "2018-04-01",
47 : type = :NRT
48 : ),
49 : :VIIRS_NOAA20_SP => (
50 : name = "VIIRS_NOAA20_SP",
51 : description = "VIIRS 375m NOAA-20 Standard Processing",
52 : start_date = "2018-04-01",
53 : type = :SP
54 : ),
55 : :VIIRS_NOAA21_NRT => (
56 : name = "VIIRS_NOAA21_NRT",
57 : description = "VIIRS 375m NOAA-21 Near Real-Time",
58 : start_date = "2024-01-17",
59 : type = :NRT
60 : ),
61 : :LANDSAT_NRT => (
62 : name = "LANDSAT_NRT",
63 : description = "Landsat 8/9 30m Near Real-Time (US/Canada only)",
64 : start_date = "2022-06-20",
65 : type = :NRT
66 : ),
67 : )
68 :
69 : # Common bounding boxes for convenience
70 : const REGIONS = Dict{Symbol, String}(
71 : :world => "world",
72 : :conus => "-125,24,-66,50", # Continental US
73 : :alaska => "-180,51,-129,72", # Alaska
74 : :california => "-125,32,-114,42", # California
75 : :western_us => "-125,31,-102,49", # Western US
76 : :eastern_us => "-102,24,-66,50", # Eastern US
77 : :canada => "-141,41,-52,84", # Canada
78 : :australia => "112,-44,154,-10", # Australia
79 : :europe => "-25,35,40,72", # Europe
80 : :amazon => "-82,-20,-34,13", # Amazon basin
81 : :africa => "-18,-35,52,38", # Africa
82 : )
83 :
84 : #-----------------------------------------------------------------------------# MAP_KEY Management
85 :
86 : """
87 : get_map_key()
88 :
89 : Get the current FIRMS MAP_KEY from the `FIRMS_MAP_KEY` environment variable.
90 :
91 : Returns `nothing` if not configured.
92 : """
93 10 : get_map_key() = get(ENV, "FIRMS_MAP_KEY", nothing)
94 :
95 : """
96 : set_map_key!(key::String)
97 :
98 : Set the FIRMS MAP_KEY by setting the `FIRMS_MAP_KEY` environment variable.
99 :
100 : You can obtain a free MAP_KEY by registering at:
101 : https://firms.modaps.eosdis.nasa.gov/api/map_key/
102 :
103 : # Example
104 : ```julia
105 : FIRMS.set_map_key!("your-32-character-map-key-here")
106 : ```
107 : """
108 1 : function set_map_key!(key::String)
109 50 : if length(key) != 32 || !all(c -> isletter(c) || isdigit(c), key)
110 0 : @warn "MAP_KEY should be a 32-character alphanumeric string"
111 : end
112 1 : ENV["FIRMS_MAP_KEY"] = key
113 1 : return nothing
114 : end
115 :
116 : function require_map_key()
117 7 : key = get_map_key()
118 14 : if isnothing(key)
119 0 : error("""
120 : FIRMS MAP_KEY not configured.
121 :
122 : To use the FIRMS API, you need a free MAP_KEY:
123 : 1. Register at: https://firms.modaps.eosdis.nasa.gov/api/map_key/
124 : 2. Set it via: FIRMS.set_map_key!("your-key")
125 : Or set the FIRMS_MAP_KEY environment variable
126 : """)
127 : end
128 7 : return key
129 : end
130 :
131 : #-----------------------------------------------------------------------------# Info Functions
132 :
133 : """
134 : info()
135 :
136 : Print information about the FIRMS API and available data sources.
137 : """
138 1 : function info()
139 1 : println("NASA FIRMS: Fire Information for Resource Management System")
140 1 : println("=" ^ 60)
141 1 : println("API: $API_BASE")
142 1 : println()
143 1 : println("Satellite fire detection data from MODIS, VIIRS, and Landsat.")
144 1 : println("Data available within 3 hours of satellite observation globally,")
145 1 : println("real-time for US/Canada.")
146 1 : println()
147 1 : println("Rate Limit: 5000 transactions per 10-minute interval")
148 1 : println()
149 :
150 1 : key = get_map_key()
151 2 : if isnothing(key)
152 0 : println("Status: MAP_KEY not configured")
153 0 : println("Register for free at: https://firms.modaps.eosdis.nasa.gov/api/map_key/")
154 : else
155 1 : println("Status: MAP_KEY configured")
156 : end
157 :
158 1 : return nothing
159 : end
160 :
161 : """
162 : sources(; type=nothing)
163 :
164 : List available FIRMS data sources.
165 :
166 : # Arguments
167 : - `type::Symbol`: Filter by type (`:NRT` for Near Real-Time, `:SP` for Standard Processing)
168 :
169 : # Example
170 : ```julia
171 : FIRMS.sources() # all sources
172 : FIRMS.sources(type=:NRT) # only NRT sources
173 : ```
174 : """
175 3 : function sources(; type::Union{Symbol, Nothing}=nothing)
176 3 : if isnothing(type)
177 1 : return SOURCES
178 : else
179 2 : return Dict(k => v for (k, v) in SOURCES if v.type == type)
180 : end
181 : end
182 :
183 : """
184 : regions()
185 :
186 : List predefined geographic regions for convenience queries.
187 :
188 : # Example
189 : ```julia
190 : FIRMS.regions()
191 : ```
192 : """
193 1 : regions() = REGIONS
194 :
195 : #-----------------------------------------------------------------------------# API Functions
196 :
197 : """
198 : query_url(source::Symbol, area::String, days::Int; date=nothing)
199 :
200 : Build a FIRMS API query URL.
201 :
202 : # Arguments
203 : - `source::Symbol`: Data source (see `FIRMS.sources()`)
204 : - `area::String`: Bounding box as "west,south,east,north" or "world"
205 : - `days::Int`: Number of days (1-10)
206 : - `date`: Optional date (Date or "YYYY-MM-DD" string) for historical queries
207 : """
208 18 : function query_url(source::Symbol, area::String, days::Int; date::Union{Date, String, Nothing}=nothing)
209 11 : haskey(SOURCES, source) || error("Unknown source: $source. Use `FIRMS.sources()` to list available sources.")
210 8 : 1 <= days <= 10 || error("days must be between 1 and 10")
211 :
212 12 : key = require_map_key()
213 6 : source_name = SOURCES[source].name
214 :
215 6 : url = "$API_BASE/area/csv/$key/$source_name/$area/$days"
216 :
217 6 : if !isnothing(date)
218 1 : date_str = date isa Date ? Dates.format(date, "yyyy-mm-dd") : date
219 1 : url *= "/$date_str"
220 : end
221 :
222 6 : return url
223 : end
224 :
225 : """
226 : download(source::Symbol; area="world", region=nothing, days=1, date=nothing, verbose=true)
227 :
228 : Download active fire data from FIRMS.
229 :
230 : # Arguments
231 : - `source::Symbol`: Data source (e.g., `:VIIRS_NOAA20_NRT`, `:MODIS_NRT`)
232 : - `area::String`: Bounding box as "west,south,east,north" (default: "world")
233 : - `region::Symbol`: Use a predefined region instead of area (see `FIRMS.regions()`)
234 : - `days::Int`: Number of days to query (1-10, default: 1)
235 : - `date`: Specific date for historical data (Date or "YYYY-MM-DD" string)
236 : - `verbose::Bool`: Print progress information
237 :
238 : # Returns
239 : A `DataFrame` containing the fire detection data.
240 :
241 : # Examples
242 : ```julia
243 : # Download last day of VIIRS NOAA-20 NRT data worldwide
244 : df = FIRMS.download(:VIIRS_NOAA20_NRT)
245 :
246 : # Download 3 days of data for California
247 : df = FIRMS.download(:VIIRS_NOAA20_NRT, region=:california, days=3)
248 :
249 : # Download data for a specific bounding box
250 : df = FIRMS.download(:MODIS_NRT, area="-120,35,-115,40", days=2)
251 :
252 : # Download historical data for a specific date
253 : df = FIRMS.download(:VIIRS_SNPP_SP, region=:western_us, days=1, date="2023-08-15")
254 : ```
255 : """
256 10 : function download(source::Symbol; area::String="world", region::Union{Symbol, Nothing}=nothing,
257 : days::Int=1, date::Union{Date, String, Nothing}=nothing, verbose::Bool=true)
258 :
259 : # Handle region shortcut
260 5 : if !isnothing(region)
261 3 : haskey(REGIONS, region) || error("Unknown region: $region. Use `FIRMS.regions()` to list available regions.")
262 3 : area = REGIONS[region]
263 : end
264 :
265 5 : url = query_url(source, area, days; date=date)
266 :
267 4 : verbose && println("Downloading: $(SOURCES[source].description)")
268 4 : verbose && println("Area: $area")
269 4 : verbose && println("Days: $days")
270 4 : !isnothing(date) && verbose && println("Date: $date")
271 4 : verbose && println("URL: $(replace(url, get_map_key() => "[MAP_KEY]"))")
272 :
273 4 : response = HTTP.get(url; status_exception=false, connect_timeout=60, readtimeout=60)
274 :
275 4 : if response.status != 200
276 0 : error("Failed to download data. HTTP status: $(response.status)\nResponse: $(String(response.body))")
277 : end
278 :
279 4 : body = String(response.body)
280 :
281 : # Check for error messages
282 12 : if startswith(body, "Invalid") || startswith(body, "Error") || contains(body, "exceeded")
283 0 : error("FIRMS API error: $body")
284 : end
285 :
286 : # Parse CSV
287 4 : verbose && println("Parsing CSV...")
288 4 : df = CSV.read(IOBuffer(body), DataFrame)
289 :
290 4 : verbose && println("Downloaded $(nrow(df)) fire detections")
291 :
292 4 : return df
293 : end
294 :
295 : """
296 : download_file(source::Symbol; filename=nothing, force=false, verbose=true, kwargs...)
297 :
298 : Download FIRMS data and save it to the local data directory.
299 :
300 : # Arguments
301 : - `source::Symbol`: Data source
302 : - `filename::String`: Custom filename (default: auto-generated)
303 : - `force::Bool`: Overwrite existing file
304 : - `verbose::Bool`: Print progress information
305 : - `kwargs...`: Additional arguments passed to `download()`
306 :
307 : # Returns
308 : The path to the downloaded CSV file.
309 :
310 : # Example
311 : ```julia
312 : path = FIRMS.download_file(:VIIRS_NOAA20_NRT, region=:california, days=3)
313 : ```
314 : """
315 2 : function download_file(source::Symbol; filename::Union{String, Nothing}=nothing,
316 : force::Bool=false, verbose::Bool=true, kwargs...)
317 1 : mkpath(dir())
318 :
319 1 : if isnothing(filename)
320 0 : date_str = Dates.format(today(), "yyyymmdd")
321 0 : filename = "$(SOURCES[source].name)_$date_str.csv"
322 : end
323 1 : filepath = joinpath(dir(), filename)
324 :
325 1 : if isfile(filepath) && !force
326 0 : verbose && println("File already exists: $filepath")
327 0 : verbose && println("Use `force=true` to overwrite.")
328 0 : return filepath
329 : end
330 :
331 1 : df = download(source; verbose=verbose, kwargs...)
332 :
333 1 : CSV.write(filepath, df)
334 1 : verbose && println("Saved to: $filepath")
335 :
336 1 : return filepath
337 : end
338 :
339 : """
340 : load_file(filename::String)
341 :
342 : Load a previously downloaded FIRMS CSV file.
343 :
344 : # Example
345 : ```julia
346 : df = FIRMS.load_file("VIIRS_NOAA20_NRT_20240115.csv")
347 : ```
348 : """
349 2 : function load_file(filename::String)
350 2 : filepath = joinpath(dir(), filename)
351 2 : if !isfile(filepath)
352 1 : error("File not found: $filepath")
353 : end
354 1 : return CSV.read(filepath, DataFrame)
355 : end
356 :
357 : #-----------------------------------------------------------------------------# Data Availability
358 :
359 : """
360 : data_availability(source::Symbol=:VIIRS_NOAA20_NRT)
361 :
362 : Check data availability for a specific source.
363 :
364 : Returns a DataFrame showing available dates and their status.
365 :
366 : # Example
367 : ```julia
368 : FIRMS.data_availability(:VIIRS_NOAA20_NRT)
369 : ```
370 : """
371 2 : function data_availability(source::Symbol=:VIIRS_NOAA20_NRT)
372 3 : haskey(SOURCES, source) || error("Unknown source: $source")
373 :
374 2 : key = require_map_key()
375 1 : source_name = SOURCES[source].name
376 1 : url = "$API_BASE/data_availability/csv/$key/$source_name"
377 :
378 1 : response = HTTP.get(url; status_exception=false, connect_timeout=60, readtimeout=60)
379 1 : if response.status != 200
380 0 : error("Failed to get data availability. HTTP status: $(response.status)")
381 : end
382 :
383 1 : return CSV.read(IOBuffer(String(response.body)), DataFrame)
384 : end
385 :
386 : #-----------------------------------------------------------------------------# Convenience Functions
387 :
388 : """
389 : recent_fires(; source=:VIIRS_NOAA20_NRT, region=:conus, days=1, min_confidence=nothing)
390 :
391 : Get recent fire detections with optional confidence filtering.
392 :
393 : # Arguments
394 : - `source::Symbol`: Data source (default: `:VIIRS_NOAA20_NRT`)
395 : - `region::Symbol`: Geographic region (default: `:conus`)
396 : - `days::Int`: Number of days (default: 1)
397 : - `min_confidence::Real`: Minimum confidence value to include (source-dependent)
398 :
399 : # Example
400 : ```julia
401 : df = FIRMS.recent_fires(region=:california, days=2)
402 : ```
403 : """
404 1 : function recent_fires(; source::Symbol=:VIIRS_NOAA20_NRT, region::Symbol=:conus,
405 : days::Int=1, min_confidence::Union{Real, Nothing}=nothing)
406 1 : df = download(source; region=region, days=days, verbose=false)
407 :
408 1 : if !isnothing(min_confidence) && "confidence" in names(df)
409 : # VIIRS confidence can be "l", "n", "h" (low, nominal, high) or numeric
410 : # MODIS confidence is 0-100
411 0 : if eltype(df.confidence) <: Number
412 0 : df = filter(row -> row.confidence >= min_confidence, df)
413 : end
414 : end
415 :
416 1 : return df
417 : end
418 :
419 : """
420 : hotspots_by_date(; source=:VIIRS_NOAA20_NRT, region=:conus, days=7)
421 :
422 : Get fire detections grouped by date.
423 :
424 : # Returns
425 : A dictionary mapping dates to DataFrames of fire detections.
426 :
427 : # Example
428 : ```julia
429 : by_date = FIRMS.hotspots_by_date(region=:western_us, days=5)
430 : ```
431 : """
432 0 : function hotspots_by_date(; source::Symbol=:VIIRS_NOAA20_NRT, region::Symbol=:conus, days::Int=7)
433 0 : df = download(source; region=region, days=days, verbose=false)
434 :
435 0 : if "acq_date" in names(df)
436 0 : return Dict(date => filter(row -> row.acq_date == date, df) for date in unique(df.acq_date))
437 : else
438 0 : return Dict("all" => df)
439 : end
440 : end
441 :
442 : end # module
|