Line data Source code
1 : module MTBS
2 :
3 : using ..WildfireData: WildfireData, AbstractDataset,
4 : _download, _download_file, _load_file, _count, _fields
5 : using Downloads
6 :
7 :
8 : #-----------------------------------------------------------------------------# Data Directory
9 4 : dir() = WildfireData.dir("MTBS")
10 :
11 : #-----------------------------------------------------------------------------# Constants
12 :
13 : # ArcGIS MapServer (for queries)
14 : const MAPSERVER_BASE = "https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_MTBS_01/MapServer"
15 :
16 : # Direct file downloads from USDA Forest Service
17 : const DOWNLOAD_BASE = "https://data.fs.usda.gov/geodata/edw"
18 :
19 : # Layer IDs in the MapServer
20 : const LAYER_FIRE_OCCURRENCE = 62 # Fire Occurrence Locations (All Years) - points
21 : const LAYER_BURN_BOUNDARIES = 63 # Burned Area Boundaries (All Years) - polygons
22 :
23 : # Time coverage
24 : const YEAR_START = 1984
25 : const YEAR_END = 2024
26 :
27 : # Fire size thresholds (acres)
28 : const SIZE_THRESHOLD_WEST = 1000 # Western US
29 : const SIZE_THRESHOLD_EAST = 500 # Eastern US
30 :
31 : # Available direct downloads
32 : const DOWNLOADS = Dict(
33 : :burn_boundaries_shp => (
34 : url = "$DOWNLOAD_BASE/edw_resources/shp/S_USA.MTBS_BURN_AREA_BOUNDARY.zip",
35 : filename = "MTBS_Burn_Area_Boundary.zip",
36 : description = "Burn area boundaries shapefile (~374 MB)"
37 : ),
38 : :burn_boundaries_gdb => (
39 : url = "$DOWNLOAD_BASE/edw_resources/fc/S_USA.MTBS_BURN_AREA_BOUNDARY.gdb.zip",
40 : filename = "MTBS_Burn_Area_Boundary.gdb.zip",
41 : description = "Burn area boundaries geodatabase (~158 MB)"
42 : ),
43 : :fire_occurrence_shp => (
44 : url = "$DOWNLOAD_BASE/edw_resources/shp/S_USA.MTBS_FIRE_OCCURRENCE_PT.zip",
45 : filename = "MTBS_Fire_Occurrence.zip",
46 : description = "Fire occurrence points shapefile (~3 MB)"
47 : ),
48 : :fire_occurrence_gdb => (
49 : url = "$DOWNLOAD_BASE/edw_resources/fc/S_USA.MTBS_FIRE_OCCURRENCE_PT.gdb.zip",
50 : filename = "MTBS_Fire_Occurrence.gdb.zip",
51 : description = "Fire occurrence points geodatabase (~2 MB)"
52 : ),
53 : )
54 :
55 : #-----------------------------------------------------------------------------# Dataset Definitions
56 :
57 : """
58 : MTBSDataset <: AbstractDataset
59 :
60 : Metadata for an MTBS MapServer layer.
61 : """
62 : struct MTBSDataset <: AbstractDataset
63 : base_url::String
64 : layer::Int
65 : name::String
66 : description::String
67 : geometry_type::Symbol # :point or :polygon
68 : end
69 :
70 19 : WildfireData.base_query_url(d::MTBSDataset) = "$(d.base_url)/$(d.layer)/query"
71 2 : WildfireData.base_layer_url(d::MTBSDataset) = "$(d.base_url)/$(d.layer)"
72 :
73 : const DATASETS = Dict{Symbol, MTBSDataset}(
74 : :fire_occurrence => MTBSDataset(
75 : MAPSERVER_BASE,
76 : LAYER_FIRE_OCCURRENCE,
77 : "Fire Occurrence Locations (All Years)",
78 : "Point locations of all inventoried MTBS fires from 1984 to present. Includes fire name, date, acres, and burn severity assessment data.",
79 : :point
80 : ),
81 : :burn_boundaries => MTBSDataset(
82 : MAPSERVER_BASE,
83 : LAYER_BURN_BOUNDARIES,
84 : "Burned Area Boundaries (All Years)",
85 : "Polygon boundaries of burned areas from 1984 to present. Includes fire perimeters with burn severity thresholds.",
86 : :polygon
87 : ),
88 : )
89 :
90 : #-----------------------------------------------------------------------------# URL Builder
91 :
92 : """
93 : query_url(dataset::Symbol; kwargs...)
94 :
95 : Build a MapServer query URL for the dataset.
96 : """
97 14 : function query_url(dataset::Symbol; kwargs...)
98 8 : haskey(DATASETS, dataset) || error("Unknown dataset: $dataset. Use `MTBS.datasets()` to list available datasets.")
99 6 : WildfireData.query_url(DATASETS[dataset]; kwargs...)
100 : end
101 :
102 : #-----------------------------------------------------------------------------# API Functions
103 :
104 : """
105 : datasets()
106 :
107 : List available MTBS datasets.
108 :
109 : # Example
110 : ```julia
111 : MTBS.datasets()
112 : ```
113 : """
114 2 : datasets() = DATASETS
115 :
116 : """
117 : info(dataset::Symbol)
118 :
119 : Print information about a specific dataset.
120 :
121 : # Example
122 : ```julia
123 : MTBS.info(:fire_occurrence)
124 : MTBS.info(:burn_boundaries)
125 : ```
126 : """
127 3 : function info(dataset::Symbol)
128 6 : if !haskey(DATASETS, dataset)
129 1 : error("Unknown dataset: $dataset. Use `MTBS.datasets()` to list available datasets.")
130 : end
131 2 : d = DATASETS[dataset]
132 2 : println("Dataset: ", d.name)
133 2 : println("Geometry: ", d.geometry_type)
134 2 : println("Description: ", d.description)
135 2 : println("Layer ID: ", d.layer)
136 2 : println("Query URL: ", query_url(dataset))
137 2 : return nothing
138 : end
139 :
140 : """
141 : download(dataset::Symbol; where="1=1", fields="*", limit=nothing, bbox=nothing, verbose=true)
142 :
143 : Download an MTBS dataset and return it as parsed GeoJSON.
144 :
145 : !!! note "MapServer Record Limit"
146 : The MTBS MapServer enforces a maximum of 2000 records per request. If your query
147 : matches more than 2000 records, results will be silently truncated. Use `MTBS.count()`
148 : to check the total before downloading, and use `where` filters or `limit` to stay
149 : within bounds. For the full dataset, use `MTBS.download_shapefile()` instead.
150 :
151 : # Arguments
152 : - `dataset::Symbol`: The dataset key (`:fire_occurrence` or `:burn_boundaries`)
153 : - `where::String`: SQL-like where clause (default: "1=1" for all records)
154 : - `fields::String`: Comma-separated field names or "*" for all
155 : - `limit::Int`: Maximum number of features to return (default: server max of 2000)
156 : - `bbox`: Bounding box for spatial filtering, as `(west, south, east, north)` tuple or `"west,south,east,north"` string
157 : - `verbose::Bool`: Print progress information
158 :
159 : # Common Fields
160 : - `:fire_occurrence`: `fire_id`, `fire_name`, `fire_type`, `ig_date`, `acres`, `latitude`, `longitude`
161 : - `:burn_boundaries`: `fire_id`, `fire_name`, `fire_type`, `year`, `ig_date`, `acres`
162 : - `fire_id` begins with the two-letter state code. `ig_date` is epoch milliseconds.
163 :
164 : # Returns
165 : A `GeoJSON.FeatureCollection`.
166 :
167 : # Examples
168 : ```julia
169 : # Download fire occurrence points (limited to 100)
170 : data = MTBS.download(:fire_occurrence, limit=100)
171 :
172 : # Download fires in California
173 : data = MTBS.download(:fire_occurrence, where="fire_id LIKE 'CA%'", limit=100)
174 :
175 : # Download large fires (over 10,000 acres)
176 : data = MTBS.download(:burn_boundaries, where="acres > 10000", limit=50)
177 :
178 : # Download fires from a specific year
179 : data = MTBS.download(:burn_boundaries, where="year = 2020", limit=100)
180 :
181 : # Download fires within a bounding box (Colorado)
182 : data = MTBS.download(:fire_occurrence, bbox=(-109, 37, -102, 41), limit=500)
183 : ```
184 : """
185 20 : download(dataset::Symbol; kwargs...) = _download(DATASETS, dataset, "MTBS"; kwargs...)
186 :
187 : """
188 : download_file(dataset::Symbol; filename=nothing, force=false, verbose=true, kwargs...)
189 :
190 : Download an MTBS dataset and save it to the local data directory as GeoJSON.
191 :
192 : # Arguments
193 : - `dataset::Symbol`: The dataset key (`:fire_occurrence` or `:burn_boundaries`)
194 : - `filename::String`: Custom filename (default: dataset key + .geojson)
195 : - `force::Bool`: Overwrite existing file if it exists
196 : - `verbose::Bool`: Print progress information
197 : - `kwargs...`: Additional arguments passed to `download()`
198 :
199 : # Returns
200 : The path to the downloaded file.
201 :
202 : # Example
203 : ```julia
204 : path = MTBS.download_file(:fire_occurrence, limit=1000)
205 : ```
206 : """
207 2 : download_file(dataset::Symbol; kwargs...) = _download_file(DATASETS, dataset, "MTBS", dir(); kwargs...)
208 :
209 : """
210 : load_file(dataset::Symbol; filename=nothing)
211 :
212 : Load a previously downloaded dataset from the local data directory.
213 :
214 : # Example
215 : ```julia
216 : MTBS.download_file(:fire_occurrence, limit=100) # download first
217 : data = MTBS.load_file(:fire_occurrence)
218 : ```
219 : """
220 4 : load_file(dataset::Symbol; kwargs...) = _load_file(dir(), dataset; kwargs...)
221 :
222 : """
223 : count(dataset::Symbol; where="1=1")
224 :
225 : Get the count of features in a dataset matching the where clause.
226 :
227 : # Example
228 : ```julia
229 : MTBS.count(:fire_occurrence) # total count
230 : MTBS.count(:burn_boundaries, where="year = 2020") # 2020 fires
231 : MTBS.count(:fire_occurrence, where="acres > 10000") # large fires
232 : ```
233 : """
234 8 : count(dataset::Symbol; kwargs...) = _count(DATASETS, dataset, "MTBS"; kwargs...)
235 :
236 : """
237 : fields(dataset::Symbol)
238 :
239 : Get the field names and types for a dataset.
240 :
241 : # Example
242 : ```julia
243 : MTBS.fields(:fire_occurrence)
244 : MTBS.fields(:burn_boundaries)
245 : ```
246 : """
247 3 : fields(dataset::Symbol) = _fields(DATASETS, dataset, "MTBS")
248 :
249 : #-----------------------------------------------------------------------------# Direct Download Functions
250 :
251 : """
252 : available_downloads()
253 :
254 : List available direct file downloads (shapefiles and geodatabases).
255 :
256 : # Example
257 : ```julia
258 : MTBS.available_downloads()
259 : ```
260 : """
261 1 : function available_downloads()
262 1 : println("Available MTBS Direct Downloads:")
263 1 : println("=" ^ 40)
264 2 : for (key, info) in DOWNLOADS
265 4 : println("\n:$key")
266 4 : println(" $(info.description)")
267 7 : end
268 1 : println("\nUse `MTBS.download_shapefile(:key)` to download.")
269 1 : return keys(DOWNLOADS)
270 : end
271 :
272 : """
273 : download_shapefile(key::Symbol; force=false, verbose=true)
274 :
275 : Download a shapefile or geodatabase directly from USDA Forest Service.
276 :
277 : # Arguments
278 : - `key::Symbol`: Download key (see `MTBS.available_downloads()`)
279 : - `force::Bool`: Re-download even if file exists
280 : - `verbose::Bool`: Print progress information
281 :
282 : # Available keys
283 : - `:burn_boundaries_shp` - Burn area boundaries shapefile (~374 MB)
284 : - `:burn_boundaries_gdb` - Burn area boundaries geodatabase (~158 MB)
285 : - `:fire_occurrence_shp` - Fire occurrence points shapefile (~3 MB)
286 : - `:fire_occurrence_gdb` - Fire occurrence points geodatabase (~2 MB)
287 :
288 : # Returns
289 : The path to the downloaded file.
290 :
291 : # Example
292 : ```julia
293 : path = MTBS.download_shapefile(:fire_occurrence_shp)
294 : ```
295 : """
296 2 : function download_shapefile(key::Symbol; force::Bool=false, verbose::Bool=true)
297 2 : if !haskey(DOWNLOADS, key)
298 1 : error("Unknown download key: $key. Use `MTBS.available_downloads()` to list options.")
299 : end
300 :
301 0 : info = DOWNLOADS[key]
302 0 : mkpath(dir())
303 0 : filepath = joinpath(dir(), info.filename)
304 :
305 0 : if isfile(filepath) && !force
306 0 : verbose && println("File already exists: $filepath")
307 0 : verbose && println("Use `force=true` to re-download.")
308 0 : return filepath
309 : end
310 :
311 0 : verbose && println("Downloading: $(info.description)")
312 0 : verbose && println("URL: $(info.url)")
313 :
314 0 : Downloads.download(info.url, filepath)
315 :
316 0 : verbose && println("Saved to: $filepath")
317 0 : return filepath
318 : end
319 :
320 : #-----------------------------------------------------------------------------# Convenience Functions
321 :
322 : """
323 : fires(; year=nothing, min_acres=nothing, max_acres=nothing, fire_type=nothing, limit=1000)
324 :
325 : Query fire occurrence points with optional filters.
326 :
327 : Note: The MapServer enforces a maximum of 2000 records per request.
328 :
329 : # Arguments
330 : - `year::Int`: Filter by year (1984-2024)
331 : - `min_acres::Real`: Minimum fire size in acres
332 : - `max_acres::Real`: Maximum fire size in acres
333 : - `fire_type::String`: Fire type (e.g., "Wildfire", "Prescribed Fire")
334 : - `limit::Int`: Maximum number of records (default: 1000, server max: 2000)
335 :
336 : # Returns
337 : A `JSON3.Object` containing the GeoJSON FeatureCollection.
338 :
339 : # Examples
340 : ```julia
341 : # Get fires from 2020
342 : data = MTBS.fires(year=2020)
343 :
344 : # Get large wildfires
345 : data = MTBS.fires(min_acres=50000, fire_type="Wildfire", limit=100)
346 : ```
347 : """
348 5 : function fires(; year::Union{Int,Nothing}=nothing,
349 : min_acres::Union{Real,Nothing}=nothing,
350 : max_acres::Union{Real,Nothing}=nothing,
351 : fire_type::Union{String,Nothing}=nothing,
352 : limit::Int=1000)
353 3 : conditions = String[]
354 :
355 : # The fire occurrence layer has no year field
356 3 : !isnothing(year) && push!(conditions, "EXTRACT(YEAR FROM ig_date) = $year")
357 3 : !isnothing(min_acres) && push!(conditions, "acres >= $min_acres")
358 3 : !isnothing(max_acres) && push!(conditions, "acres <= $max_acres")
359 3 : !isnothing(fire_type) && push!(conditions, "fire_type = '$(replace(fire_type, "'" => "''"))'")
360 :
361 :
362 5 : where_clause = isempty(conditions) ? "1=1" : join(conditions, " AND ")
363 :
364 3 : return download(:fire_occurrence; where=where_clause, limit=limit, verbose=false)
365 : end
366 :
367 : """
368 : boundaries(; year=nothing, min_acres=nothing, max_acres=nothing, limit=100)
369 :
370 : Query burn area boundaries with optional filters.
371 :
372 : Note: The MapServer enforces a maximum of 2000 records per request.
373 :
374 : # Arguments
375 : - `year::Int`: Filter by year (1984-2024)
376 : - `min_acres::Real`: Minimum fire size in acres
377 : - `max_acres::Real`: Maximum fire size in acres
378 : - `limit::Int`: Maximum number of records (default: 100, server max: 2000)
379 :
380 : # Returns
381 : A `JSON3.Object` containing the GeoJSON FeatureCollection.
382 :
383 : # Examples
384 : ```julia
385 : # Get burn boundaries from 2020
386 : data = MTBS.boundaries(year=2020)
387 :
388 : # Get large fire boundaries
389 : data = MTBS.boundaries(min_acres=100000, limit=50)
390 : ```
391 : """
392 3 : function boundaries(; year::Union{Int,Nothing}=nothing,
393 : min_acres::Union{Real,Nothing}=nothing,
394 : max_acres::Union{Real,Nothing}=nothing,
395 : limit::Int=100)
396 2 : conditions = String[]
397 :
398 2 : !isnothing(year) && push!(conditions, "year = $year")
399 2 : !isnothing(min_acres) && push!(conditions, "acres >= $min_acres")
400 2 : !isnothing(max_acres) && push!(conditions, "acres <= $max_acres")
401 :
402 3 : where_clause = isempty(conditions) ? "1=1" : join(conditions, " AND ")
403 :
404 2 : return download(:burn_boundaries; where=where_clause, limit=limit, verbose=false)
405 : end
406 :
407 : """
408 : largest_fires(n::Int=100; year=nothing)
409 :
410 : Get the n largest fires by acreage.
411 :
412 : # Examples
413 : ```julia
414 : MTBS.largest_fires(10) # top 10 largest fires ever
415 : MTBS.largest_fires(10, year=2020) # top 10 in 2020
416 : ```
417 : """
418 2 : function largest_fires(n::Int=100; year::Union{Int,Nothing}=nothing)
419 1 : where_clause = isnothing(year) ? "1=1" : "EXTRACT(YEAR FROM ig_date) = $year"
420 :
421 : # Note: MapServer doesn't support ORDER BY in the same way, so we get more records
422 : # and sort client-side
423 1 : data = download(:fire_occurrence; where=where_clause, limit=min(n * 2, 2000), verbose=false)
424 :
425 1 : if length(data) > 0
426 : # Sort by acres descending and take top n
427 45 : sorted_features = sort(collect(data), by=f -> -something(f.acres, 0))
428 1 : return sorted_features[1:min(n, length(sorted_features))]
429 : end
430 :
431 0 : return collect(data)
432 : end
433 :
434 : end # module
|