fire2a.cell2fire

Classes and methods auxiliary to using Cell2FireW and its QGIS integration

Currently:

  • Writes C2FW's firebreak specification (a.csv file) from a QGIS raster layer

Processes C2FW plain text outputs

  • Fire Scars (Grids directories) into rasters and polygons
  • Statistics (Intensity, HitRos, ... directories) into rasters

Auxiliary:

  • get_scars_files (all together but a bit slower than the next two methods)
  • get_scars_indexed (part 1/2)
  • group_scars (part 2/2)

Sample Usage:

python -m fire2a.cell2fire -vvv --base-raster ../fuels.asc --authid EPSG:25831 --scar-sample Grids/Grids2/F
orestGrid01.csv --scar-raster scar_raster.tif --scar-poly scar_poly.shp --burn-prob burn_prob.tif --stat-sample RateOfSpread/ROSFile11.asc --stat-raster ros.tif --stat-summary ros_stats.tif
  1#!python
  2# fmt: off
  3"""
  4Classes and methods auxiliary to using Cell2FireW and its QGIS integration
  5
  6Currently:
  7* Writes C2FW's firebreak specification (a.csv file) from a QGIS raster layer
  8
  9Processes C2FW plain text outputs 
 10* Fire Scars (Grids directories) into rasters and polygons
 11* Statistics (Intensity, HitRos, ... directories) into rasters
 12
 13Auxiliary:
 14* get_scars_files (all together but a bit slower than the next two methods)
 15* get_scars_indexed (part 1/2)
 16* group_scars (part 2/2)
 17
 18Sample Usage:
 19```bash
 20python -m fire2a.cell2fire -vvv --base-raster ../fuels.asc --authid EPSG:25831 --scar-sample Grids/Grids2/F
 21orestGrid01.csv --scar-raster scar_raster.tif --scar-poly scar_poly.shp --burn-prob burn_prob.tif --stat-sample RateOfSpread/ROSFile11.asc --stat-raster ros.tif --stat-summary ros_stats.tif
 22```
 23"""
 24# fmt: on
 25__author__ = "Fernando Badilla"
 26__revision__ = "$Format:%H$"
 27
 28import logging
 29import sys
 30from pathlib import Path
 31
 32from qgis.core import QgsRasterLayer
 33
 34from fire2a import setup_file
 35from fire2a.utils import count_header_lines, fprint, loadtxt_nodata
 36
 37logger = logging.getLogger(__name__)
 38"""captures the local logger"""
 39
 40NAME, FILEPATH = setup_file(name="cell2fire", filepath=Path("/home/fdo/source/fire2a-lib/src/fire2a"))
 41"""setups the file name and path for logger (set path manually when pasting into ipython or jupyter session)"""
 42
 43
 44def raster_layer_to_firebreak_csv(layer: QgsRasterLayer, firebreak_val: int = 1, output_file="firebreaks.csv") -> None:
 45    """Write a (Cell2) Fire Simulator (C2FW) firebreak csv file from a QGIS raster layer  
 46    Usage as cli argument `Cell2Fire --FirebreakCells firebreaks.csv ...`
 47
 48    Args:
 49    -   layer (QgsRasterLayer): A QGIS raster layer, default is the active layer
 50    -   firebreak_val (int): The value used to identify the firebreaks, default is 666
 51    -   output_file (str or Path): The path to the output csv file, default is firebreaks.csv
 52
 53    QGIS Desktop Example: Choose method A or B, draw with serval (2)
 54
 55    1.A. Use the 'Create constant raster layer' tool to create one with the same extent extent, crs and pixel size than the fuels raster layer. Recommended: 
 56    - constant value = 0
 57    - (Advanced Parameters) output raster data type = Byte
 58
 59    1.B. Use the 'raster calculator' with a base layer and 0 in the formula
 60
 61    2. Use 'Serval' plugin-tool to draw with the mouse the firebreaks on the new raster layer (values =1). Reload or save as to see changes.
 62
 63    QGIS Python Console Example:  
 64    ```
 65    layer = iface.activeLayer()  
 66    from fire2a.firebreaks import raster_layer_to_firebreak_csv
 67    raster_layer_to_firebreak_csv(layer)
 68    import processing
 69    processing.run("fire2a:cell2firesimulator", ...
 70    ```
 71    See also: https://fire2a.github.io/docs/docs/qgis-toolbox/c2f_firebreaks.html
 72    """  # fmt: skip
 73    from numpy import array as np_array
 74    from numpy import where as np_where
 75
 76    from fire2a.raster import get_rlayer_data, xy2id
 77
 78    width = layer.width()
 79    data = get_rlayer_data(layer)
 80
 81    # numpy is hh,ww indexing
 82    yy, xx = np_where(data == firebreak_val)
 83    ids = np_array([xy2id(x, y, width) for x, y in zip(xx, yy)])
 84    ids += 1
 85
 86    with open(output_file, "w") as f:
 87        f.write("Year,Ncell\n")
 88        f.write(f"1,{','.join(map(str,ids))}\n")
 89
 90
 91def get_scars_files(sample_file: Path):
 92    """Get sorted lists of (non-empty) files matching the pattern `root/parent(+any digit)/children(+any digit).(any extension)`
 93
 94    Normally used to read Cell2FireW scars `results/Grids/Grids*/ForestGrid*.csv`
 95
 96    Args:
 97    - sample_file (Path): A sample file to extract the name and extension, parent and root directories
 98
 99    Returns a tuple:
100    - bool: True if successful, False otherwise.  
101    - str: Error message, if any.  
102    - Path: `root` - all other paths are relative to this and must be used as root/parent or root/child.  
103    - list[Path]: `parents` - sorted list of parent directories.  
104    - list[int]: `parents_ids` - corresponding simulation ids of these parents.  
105    - list[list[Path]]: `children` - sorted list of lists of children files (grouped by simulation)
106    - list[list[int]]: `children_ids` - list of lists of corresponding period ids of each simulation
107
108    Sample Usage:
109    ```python
110    ret_val, msg, root, parent_dirs, parent_ids, children, children_ids = get_scars_files(Path(sample_file))
111    if not ret_val:
112        logger.error(msg)
113    ```
114    """  # fmt: skip
115    from re import search as re_search
116
117    ext = sample_file.suffix
118    if match := re_search(r"(\d+)$", sample_file.stem):
119        num = match.group()
120    else:
121        msg = f"sample_file: {sample_file} does not contain a number at the end"
122        return False, msg, None, None, None, None, None
123    file_name_wo_num = sample_file.stem[: -len(num)]
124    parent = sample_file.absolute().parent
125    root = sample_file.absolute().parent.parent
126    parent = parent.relative_to(root)
127    if match := re_search(r"(\d+)$", parent.name):
128        num = match.group()
129    else:
130        msg = f"sample_file:{sample_file} parent:{parent} does not contain a number at the end"
131        return False, msg, root, None, None, None, None
132
133    parent_wo_num = parent.name[: -len(num)]
134    parent_dirs = []
135    parent_ids = []
136    for par in root.glob(parent_wo_num + "[0-9]*"):
137        if par.is_dir():
138            par = par.relative_to(root)
139            parent_ids += [int(re_search(r"(\d+)$", par.name).group(0))]
140            parent_dirs += [par]
141    adict = dict(zip(parent_dirs, parent_ids))
142    parent_dirs.sort(key=lambda x: adict[x])
143    parent_ids.sort()
144
145    children = []
146    children_ids = []
147    for par in parent_dirs:
148        chl_files = []
149        chl_ids = []
150        for afile in (root / par).glob(file_name_wo_num + "[0-9]*" + ext):
151            if afile.is_file() and afile.stat().st_size > 0:
152                afile = afile.relative_to(root)
153                chl_ids += [int(re_search(r"(\d+)$", afile.stem).group(0))]
154                chl_files += [afile]
155        adict = dict(zip(chl_files, chl_ids))
156        chl_files.sort(key=lambda x: adict[x])
157        chl_ids.sort()
158        children += [chl_files]
159        children_ids += [chl_ids]
160
161    # msg = f"Got {len(parent_dirs)} parent directories with {sum([len(chl) for chl in children])} children files"
162    msg = ""
163    return True, msg, root, parent_dirs, parent_ids, children, children_ids
164
165
166def get_scars_indexed(sample_file: Path):
167    """Get a sorted list of files with the pattern `root/parent(+any digit)/children(+any digit).(any extension)`
168
169    Args:
170    - sample_file (Path): A sample file to extract the extension, children name (wo ending number),  parent (wo ending number) and root directory
171
172    Returns a tuple:
173    - return_value (bool): True if successful, False otherwise
174    - return_message (str): Debug/Error message if any
175    - root (Path): all paths are relative to this and must be used as root/file
176    - parent_wo_num (str): parent name without the ending number
177    - child_wo_num (str): children name without the ending number
178    - extension (str): file extension
179    - files (list[Path]): sorted list of (relative paths) files
180    - indexes (list[Tuple[int,int]]]): list of tuples of simulation and period ids
181
182    Sample Usage
183    ```python
184    retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
185    if not ret_val:
186        logger.error(msg)
187    ```
188    """
189    from os import sep
190    from re import findall as re_findall
191    from re import search as re_search
192
193    from numpy import array as np_array
194    from numpy import fromiter as np_fromiter
195
196    ext = sample_file.suffix
197    if match := re_search(r"(\d+)$", sample_file.stem):
198        num = match.group()
199    else:
200        msg = f"sample_file: {sample_file} does not contain a number at the end"
201        return False, msg, None, None, None, ext, None, None
202    child_wo_num = sample_file.stem[: -len(num)]
203    parent = sample_file.absolute().parent
204    root = sample_file.absolute().parent.parent
205    parent = parent.relative_to(root)
206    if match := re_search(r"(\d+)$", parent.name):
207        num = match.group()
208    else:
209        msg = f"sample_file:{sample_file} parent:{parent} does not contain a number at the end"
210        return False, msg, root, None, child_wo_num, None, None
211
212    parent_wo_num = parent.name[: -len(num)]
213
214    files = np_array(
215        [
216            afile.relative_to(root)
217            for afile in root.glob(parent_wo_num + "[0-9]*" + sep + child_wo_num + "[0-9]*" + ext)
218            if afile.is_file() and afile.stat().st_size > 0
219        ]
220    )
221
222    if sep == "\\":
223        sep = "\\\\"
224    indexes = np_fromiter(
225        # re_findall(parent_wo_num + r"(\d+)" + sep + child_wo_num + r"(\d+)" + ext, " ".join(map(str, files))),
226        re_findall(r"(\d+)" + sep + child_wo_num + r"(\d+)", " ".join(map(str, files))),
227        dtype=[("sim", int), ("per", int)],
228        count=len(files),
229    )
230
231    files = files[indexes.argsort(order=("sim", "per"))]
232    indexes.sort()
233
234    msg = ""
235    # msg = f"Got {len(files)} files\n"
236    # msg += f"{len(np_unique(indexes['sim']))} simulations\n"
237    # msg += f"{len(np_unique(indexes['per']))} different periods"
238
239    return True, msg, root, parent_wo_num, child_wo_num, ext, files, indexes
240
241
242def group_scars(root, parent_wo_num, child_wo_num, ext, files, indexes):
243    """Group scars files by simulation and period
244
245    Args:
246    - root (Path): root directory
247    - parent_wo_num (str): parent name without the ending number
248    - child_wo_num (str): children name without the ending number
249    - ext (str): file extension
250    - files (list[Path]): list of files
251    - indexes (list[Tuple[int,int]]): list of tuples of simulation and period ids
252
253    Returns:
254    - parent_ids (list[int]): list of simulation ids
255    - parent_dirs (list[Path]): list of parent directories
256    - children_ids (list[Tuple[int,int]]): list of tuples of simulation and period ids
257    - children_files (list[Path]): list of children files
258    - final_scars_ids (list[Tuple[int,int]]): list of tuples of simulation and period ids
259    - final_scars_files (list[Path]): list of final scars files
260
261    Sample:
262    ```python
263    retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
264    if not retval:
265        logger.error(retmsg)
266        sys.exit(1)
267    parent_ids, parent_dirs, children_ids, children_files, final_scars_ids, final_scars_files = group_scars(
268        root, parent_wo_num, child_wo_num, ext, files, indexes
269    )
270    ```
271    """
272    from numpy import unique as np_unique
273    from numpy import where as np_where
274
275    parent_ids = [sim_id for sim_id in np_unique(indexes["sim"])]
276    children_ids = [[(sim_id, per_id) for per_id in indexes["per"][indexes["sim"] == sim_id]] for sim_id in parent_ids]
277    children_files = [[afile for afile in files[np_where(indexes["sim"] == pid)[0]]] for pid in parent_ids]
278
279    final_idx = [np_where(indexes["sim"] == pid)[0][-1] for pid in parent_ids]
280
281    parent_dirs = [afile.parent for afile in files[final_idx]]
282
283    final_scars_files = files[final_idx]
284    final_scars_ids = indexes[final_idx]
285
286    return parent_ids, parent_dirs, children_ids, children_files, final_scars_ids, final_scars_files
287
288
289def build_scars(
290    scar_raster: str,
291    scar_poly: str,
292    burn_prob: str,
293    sample_file: Path,
294    W: int,
295    H: int,
296    geotransform: tuple,
297    authid: str,
298    callback=None,
299    feedback=None,
300):
301    """Builds the final scars raster, evolution scars polygons and/or burn probability raster files
302
303    Args:
304    - scar_raster (str): The output file name for the final scars raster
305    - scar_poly (str): The output file name for the evolution scars polygons
306    - burn_prob (str): The output file name for the burn probability raster
307    - sample_file (Path): Any scar sample file usually `results/Grids/Grids1/ForestGrid1.csv`
308    - W (int): Width of the raster
309    - H (int): Height of the raster
310    - geotransform (tuple): The geotransform of the raster
311    - authid (str): The projection of the raster
312    - callback (function): A function to call with the progress percentage
313    - feedback (QgsFeedback): A feedback object
314
315    Returns:
316    - int: 0 if successful, 1 otherwise
317    """
318    from numpy import any as np_any
319    from numpy import float32 as np_float32
320    from numpy import int8 as np_int8
321    from numpy import loadtxt as np_loadtxt
322    from numpy import zeros as np_zeros
323    from osgeo import gdal, ogr, osr
324
325    from fire2a.processing_utils import get_output_raster_format, get_vector_driver_from_filename
326
327    gdal.UseExceptions()
328
329    retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
330    if not retval:
331        fprint(retmsg, level="error", feedback=feedback, logger=logger)
332        return 1
333    parent_ids, parent_dirs, children_ids, children_files, final_scars_ids, final_scars_files = group_scars(
334        root, parent_wo_num, child_wo_num, ext, files, indexes
335    )
336
337    if burn_prob:
338        burn_prob_arr = np_zeros((H, W), dtype=np_float32)
339    else:
340        burn_prob_arr = None
341
342    if scar_raster:
343        driver_name = get_output_raster_format(scar_raster, feedback=feedback)
344        scar_raster_ds = gdal.GetDriverByName(driver_name).Create(
345            scar_raster, W, H, len(final_scars_ids), gdal.GDT_Byte
346        )
347        scar_raster_ds.SetGeoTransform(geotransform)
348        scar_raster_ds.SetProjection(authid)
349    else:
350        scar_raster_ds = None
351
352    def final_scar_step(i, data, afile, scar_raster, scar_raster_ds, burn_prob, burn_prob_arr, feedback=None):
353        if scar_raster:
354            band = scar_raster_ds.GetRasterBand(i)
355            # band.SetUnitType("burned")
356            if 0 != band.SetNoDataValue(0):
357                fprint(
358                    f"Set NoData failed for Final Scar {i}: {afile}", level="warning", feedback=feedback, logger=logger
359                )
360            if 0 != band.WriteArray(data):
361                fprint(
362                    f"WriteArray failed for Final Scar {i}: {afile}", level="warning", feedback=feedback, logger=logger
363                )
364            if i % 100 == 0:
365                scar_raster_ds.FlushCache()
366        if burn_prob:
367            if np_any(data == -1):
368                mask = data != -1
369                burn_prob_arr[ mask ] += data[ mask ]
370            else:
371                burn_prob_arr += data
372
373    if scar_poly:
374        # raster for each grid
375        src_ds = gdal.GetDriverByName("MEM").Create("", W, H, 1, gdal.GDT_Byte)
376        src_ds.SetGeoTransform(geotransform)
377        src_ds.SetProjection(authid)  # export coords to file
378
379        # datasource for shadow geometry vector layer (polygonize output)
380        ogr_ds = ogr.GetDriverByName("Memory").CreateDataSource("")
381
382        # spatial reference
383        sp_ref = osr.SpatialReference()
384        sp_ref.SetFromUserInput(authid)
385
386        if scar_poly.startswith("memory:"):
387            driver_name = "GPKG"
388            fprint(f"Memory layer, using {driver_name} driver", level="info", feedback=feedback, logger=logger)
389        else:
390            driver_name = get_vector_driver_from_filename(scar_poly)
391            fprint(f"NOT Mem lyr, using {driver_name} driver", level="info", feedback=feedback, logger=logger)
392
393        drv = ogr.GetDriverByName(driver_name)
394        if 0 != drv.DeleteDataSource(scar_poly):
395            fprint(f"Failed to delete {scar_poly}", level="error", feedback=feedback, logger=logger)
396        otrods = drv.CreateDataSource(scar_poly)
397        otrolyr = otrods.CreateLayer("propagation_scars", srs=sp_ref, geom_type=ogr.wkbPolygon)
398        otrolyr.CreateField(ogr.FieldDefn("simulation", ogr.OFTInteger))
399        otrolyr.CreateField(ogr.FieldDefn("time", ogr.OFTInteger))
400        otrolyr.CreateField(ogr.FieldDefn("area", ogr.OFTInteger))
401        otrolyr.CreateField(ogr.FieldDefn("perimeter", ogr.OFTInteger))
402
403        count_evo = 0
404        count_fin = 0
405        total_files = sum(len(files) for files in children_files)
406
407        for sim_id, ids, files in zip(parent_ids, children_ids, children_files):
408            count_fin += 1
409            for (_, per_id), afile in zip(ids, files):
410                count_evo += 1
411
412                try:
413                    data = np_loadtxt(root / afile, delimiter=",", dtype=np_int8)
414                except:
415                    fprint(
416                        f"Error reading {afile}, retrying with nodata = 0",
417                        level="error",
418                        feedback=feedback,
419                        logger=logger,
420                    )
421                    data = loadtxt_nodata(root / afile, delimiter=",", dtype=np_int8, no_data=0)
422
423                if not np_any(data == 1):
424                    fprint(
425                        f"no fire in {afile}, skipping propagation polygon",
426                        level="warning",
427                        feedback=feedback,
428                        logger=logger,
429                    )
430                else:
431                    src_band = src_ds.GetRasterBand(1)
432                    src_band.SetNoDataValue(0)
433                    src_band.WriteArray(data)
434
435                    ogr_layer = ogr_ds.CreateLayer("", srs=sp_ref)
436                    gdal.Polygonize(src_band, src_band, ogr_layer, -1, ["8CONNECTED=8"])
437
438                    feat = ogr_layer.GetNextFeature()
439                    geom = feat.GetGeometryRef()
440                    featureDefn = otrolyr.GetLayerDefn()
441                    feature = ogr.Feature(featureDefn)
442                    feature.SetGeometry(geom)
443                    feature.SetField("simulation", int(sim_id))
444                    feature.SetField("time", int(per_id))
445                    feature.SetField("area", int(geom.GetArea()))
446                    feature.SetField("perimeter", int(geom.Boundary().Length()))
447                    otrolyr.CreateFeature(feature)
448                    if count_evo % 100 == 0:
449                        otrods.FlushCache()
450
451                if callback:
452                    callback(count_evo / len(files) * 100, f"Processed Propagation-Scar {count_evo}/{len(indexes)}")
453                else:
454                    fprint(
455                        f"Processed Propagation-Scar {count_evo}/{len(indexes)}",
456                        level="info",
457                        feedback=feedback,
458                        logger=logger,
459                    )
460
461            if scar_raster or burn_prob:
462                final_scar_step(
463                    count_fin, data, afile, scar_raster, scar_raster_ds, burn_prob, burn_prob_arr, feedback=feedback
464                )
465                if callback:
466                    callback(None, f"Processed +Final-Scar {count_evo}/{len(indexes)}")
467                else:
468                    fprint(
469                        f"Processed Final-Scar {count_fin}/{len(files)}", level="info", feedback=feedback, logger=logger
470                    )
471        # clean up
472        if scar_raster:
473            scar_raster_ds.FlushCache()
474            scar_raster_ds = None
475        otrods.FlushCache()
476        otrods = None
477        # otrolyr.SyncToDisk() CRASHES QGIS
478        # otrolyr.FlushCache()
479        otrolyr = None
480        src_ds.FlushCache()
481        src_ds = None
482    else:
483        # final scar loop
484        count_fin = 0
485        for (sim_id, per_id), afile in zip(final_scars_ids, final_scars_files):
486            count_fin += 1
487            try:
488                data = np_loadtxt(root / afile, delimiter=",", dtype=np_int8)
489            except:
490                fprint(
491                    f"Error reading {afile}, retrying with nodata = 0", level="error", feedback=feedback, logger=logger
492                )
493                data = loadtxt_nodata(root / afile, delimiter=",", dtype=np_int8, no_data=0)
494            if not np_any(data == 1):
495                fprint(f"no fire in Final-Scar {afile}", level="warning", feedback=feedback, logger=logger)
496            final_scar_step(
497                count_fin, data, afile, scar_raster, scar_raster_ds, burn_prob, burn_prob_arr, feedback=feedback
498            )
499            if callback:
500                callback(count_fin / len(final_scars_files) * 100, f"Processed Final-Scar {count_fin}/{len(files)}")
501            else:
502                fprint(f"Processed Final-Scar {count_fin}/{len(files)}", level="info", feedback=feedback, logger=logger)
503        if scar_raster:
504            scar_raster_ds.FlushCache()
505            scar_raster_ds = None
506
507    if burn_prob:
508        driver_name = get_output_raster_format(burn_prob, feedback=feedback)
509        burn_prob_ds = gdal.GetDriverByName(driver_name).Create(burn_prob, W, H, 1, gdal.GDT_Float32)
510        burn_prob_ds.SetGeoTransform(geotransform)
511        burn_prob_ds.SetProjection(authid)
512        band = burn_prob_ds.GetRasterBand(1)
513        # band.SetUnitType("probability")
514        if 0 != band.SetNoDataValue(0):
515            fprint(
516                f"Set NoData failed for Burn Probability {burn_prob}", level="warning", feedback=feedback, logger=logger
517            )
518        if 0 != band.WriteArray(burn_prob_arr / len(final_scars_files)):  # type: ignore
519            fprint(
520                f"WriteArray failed for Burn Probability {burn_prob}", level="warning", feedback=feedback, logger=logger
521            )
522        burn_prob_ds.FlushCache()
523        burn_prob_ds = None
524
525    return 0
526
527
528def glob_numbered_files(sample_file: Path) -> tuple[list[Path], Path, str, str]:
529    """Get a list of files with the same name (+ any digit) and extension and the directory and name of the sample file
530
531    Args:
532    - sample_file (Path): A sample file to extract the extension, name and directory
533      - e.g., results/Intensity/Intensity1.asc, RateOfSpread/ROSFile2.asc, FlameLength/FL.asc, CrownFractionBurn/Cfb.asc, etc.
534
535    Returns a tuple:
536    - files (list[Path]): sorted list of files
537    - adir (Path): directory of the sample file
538    - aname (str): name of the sample file
539    - ext (str): extension of the sample file
540    """
541    from re import search
542
543    ext = sample_file.suffix
544    if match := search(r"(\d+)$", sample_file.stem):
545        num = match.group()
546    else:
547        raise ValueError(f"sample_file: {sample_file} does not contain a number at the end")
548    aname = sample_file.stem[: -len(num)]
549    adir = sample_file.absolute().parent
550    files = []
551    for afile in sorted(adir.glob(aname + "[0-9]*" + ext)):
552        if afile.is_file() and afile.stat().st_size > 0:
553            files += [afile]
554    # QgsMessageLog.logMessage(f"files: {files}, adir: {adir}, aname: {aname}, ext: {ext}", "fire2a", Qgis.Info)
555    return files, adir, aname, ext
556
557
558def build_stats(
559    stat_raster: str,
560    stat_summary: str,
561    sample_file: Path,
562    W: int,
563    H: int,
564    geotransform: tuple,
565    authid: str,
566    callback=None,
567    feedback=None,
568):
569    """Builds final statistics raster (1 band per-simulation) and summary raster (2 bands: mean against pixel burn count and stdev against total number of simulations) files
570    """
571    from numpy import float32 as np_float32
572    from numpy import loadtxt as np_loadtxt
573    from numpy import sqrt as np_sqrt
574    from numpy import zeros as np_zeros
575    from osgeo import gdal
576
577    from fire2a.processing_utils import get_output_raster_format
578
579    gdal.UseExceptions()
580
581    files, root, aname, ext = glob_numbered_files(sample_file)
582    num_headers = count_header_lines(files[0], sep=" ", feedback=feedback)
583
584    if stat_raster:
585        driver_name = get_output_raster_format(stat_raster, feedback=feedback)
586        stat_raster_ds = gdal.GetDriverByName(driver_name).Create(stat_raster, W, H, len(files), gdal.GDT_Float32)
587        stat_raster_ds.SetGeoTransform(geotransform)
588        stat_raster_ds.SetProjection(authid)
589    else:
590        stat_raster_ds = None
591
592    if stat_summary:
593        summed = np_zeros((H, W), dtype=np_float32)
594        sumsquared = np_zeros((H, W), dtype=np_float32)
595        burncount = np_zeros((H, W), dtype=np_float32)
596    else:
597        summed = None
598        sumsquared = None
599        burncount = None
600
601    count = 0
602    for afile in files:
603        count += 1
604        try:
605            data = np_loadtxt(root / afile, delimiter=" ", dtype=np_float32, skiprows=num_headers)
606        except Exception as e:
607            fprint(
608                f"Error reading {afile}, retrying with nodata = -9999: {e}",
609                level="error",
610                feedback=feedback,
611                logger=logger,
612            )
613            data = loadtxt_nodata(root / afile, delimiter=" ", dtype=np_float32, skiprows=num_headers, no_data=-9999)
614
615        if stat_raster_ds:
616            band = stat_raster_ds.GetRasterBand(count)
617            if 0 != band.SetNoDataValue(0):
618                fprint(
619                    f"Set NoData failed for Statistic {count}: {afile}",
620                    level="warning",
621                    feedback=feedback,
622                    logger=logger,
623                )
624            if 0 != band.WriteArray(data):
625                fprint(
626                    f"WriteArray failed for Statistic {count}: {afile}",
627                    level="warning",
628                    feedback=feedback,
629                    logger=logger,
630                )
631            if count % 100 == 0:
632                stat_raster_ds.FlushCache()
633
634        if stat_summary:
635            mask = data != -9999
636            tmp = data[mask]
637            summed[mask] += tmp
638            sumsquared[mask] += tmp ** 2
639            burncount[mask & (data != 0)] += 1
640
641        if callback:
642            callback(count / len(files) * 100, f"Processed Statistic {count}/{len(files)}")
643        else:
644            fprint(f"Processed Statistic {count}/{len(files)}", level="info", feedback=feedback, logger=logger)
645
646    if stat_raster_ds:
647        stat_raster_ds.FlushCache()
648        stat_raster_ds = None
649
650    if stat_summary:
651        driver_name = get_output_raster_format(stat_summary, feedback=feedback)
652        stat_summary_ds = gdal.GetDriverByName(driver_name).Create(stat_summary, W, H, 2, gdal.GDT_Float32)
653        stat_summary_ds.SetGeoTransform(geotransform)
654        stat_summary_ds.SetProjection(authid)
655        # mean
656        mask = burncount != 0
657        mean = np_zeros((H, W), dtype=np_float32) - 9999
658        mean[mask] = summed[mask] / burncount[mask]
659        band = stat_summary_ds.GetRasterBand(1)
660        if 0 != band.SetNoDataValue(-9999):
661            fprint(
662                f"Set NoData failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
663            )
664        if 0 != band.WriteArray(mean):
665            fprint(
666                f"WriteArray failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
667            )
668        # std
669        # from all simulations
670        N = len(files)
671        stddev = np_sqrt(sumsquared / N - (summed/N)**2)
672        # beacause this is always zero:
673        # stddev = np_zeros((H, W), dtype=np_float32) - 9999
674        # zero_mask = burncount == 0
675        # burncount[zero_mask] = 1
676        # stddev = np_sqrt(sumsquared / burncount - mean ** 2)
677        # stddev[~mask] = -9999
678        band = stat_summary_ds.GetRasterBand(2)
679        if 0 != band.SetNoDataValue(-9999):
680            fprint(
681                f"Set NoData failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
682            )
683        if 0 != band.WriteArray(stddev):
684            fprint(
685                f"WriteArray failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
686            )
687        stat_summary_ds.FlushCache()
688        stat_summary_ds = None
689
690    return 0
691
692
693def arg_parser(argv):
694    """Arguments parses to coordinate: scar and stats processing, verbosity, logfile
695
696    Args:
697    - argv (list): list of strings
698      - e.g., interactive: `args = arg_parser(['-vvv'])`
699
700    Returns:
701    - argparse.Namespace: parsed arguments
702    """
703    import argparse
704
705    parser = argparse.ArgumentParser(
706        description=r"Fire2a-Cell2FireW Related algorithms CLI. Implemented here: Cell2FireW plain scars and statistic outputs into rasters and polygons.",
707        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
708    )
709
710    scars = parser.add_argument_group(
711        "Scars: Transform C2FW scar outputs into rasters and polygons, i.e.: results/Grids/Grids*/ForestGrid*.csv"
712    )
713    scars.add_argument(
714        "--scar-sample",
715        help="Matching the pattern 'root/parent(+any digit)/children(+any digit).(any csv extension)' ",
716    )
717    scars.add_argument(
718        "--scar-raster", default="", help="Output file name for the final scars raster, each band a simulation"
719    )
720    scars.add_argument(
721        "--scar-poly",
722        default="",
723        help="Output file name for the evolution scars polygons, multiple features with simulation and period attributes",
724    )
725    scars.add_argument("--burn-prob", default="", help="Output file name for the burn probability raster, one band")
726
727    stats = parser.add_argument_group(
728        "Stats: Transform C2FW outputs into rasters, .e.g.: results/Intensity/Intensity1.asc, RateOfSpread/ROSFile2.asc, FlameLength/FL.asc, CrownFractionBurn/Cfb.asc, etc."
729    )
730    stats.add_argument(
731        "--stat-sample",
732        default="",
733        help="Matching the pattern 'statistic_name(+any digit).asc' ",
734    )
735    stats.add_argument(
736        "--stat-raster", default="", help="Output file name for the statistic raster, each band a simulation"
737    )
738    stats.add_argument(
739        "--stat-summary", default="", help="Output file name for the raster, two bands: mean and std-deviation"
740    )
741
742    parser.add_argument("--verbose", "-v", action="count", default=0, help="WARNING:1, INFO:2, DEBUG:3")
743    parser.add_argument(
744        "--logfile",
745        "-l",
746        action="store_true",
747        help="enable 5 log files named " + NAME + ".log (verbose must be enabled)",
748        default=None,
749    )
750    parser.add_argument(
751        "--base-raster", required=True, help="Raster to base the geotransform, width, heigth and authid"
752    )
753    parser.add_argument(
754        "--authid", required=False, help="Auth id to override (or missing on the base raster (e.g., using .asc raster)"
755    )
756    args = parser.parse_args(argv)
757    if args.logfile:
758        args.logfile = NAME + ".log"
759    return args
760
761
762def main(argv=None):
763    """
764
765    args = arg_parser(['-vvv'])
766    """
767
768    if argv is sys.argv:
769        argv = sys.argv[1:]
770    args = arg_parser(argv)
771
772    if args.verbose != 0:
773        global logger
774        from fire2a import setup_logger
775
776        logger = setup_logger(verbosity=args.verbose, logfile=args.logfile)
777        # set other modules logging level
778        logging.getLogger("asyncio").setLevel(logging.INFO)
779
780    logger.info("args %s", args)
781
782    if not args.base_raster:
783        logger.error("No base raster file name provided")
784        return 1
785
786    if not Path(args.base_raster).is_file():
787        logger.error("Base raster %s is not a file", args.base_raster)
788        return 1
789
790    from fire2a.raster import read_raster
791
792    _, raster_props = read_raster(args.base_raster, data=False, info=True)
793
794    if not (authid := raster_props["Projection"]):
795        if not (authid := args.authid):
796            logger.error("No authid found on the base raster or provided")
797            return 1
798    logger.info("Read base raster, using authid: %s", authid)
799
800    retval = {}
801    if args.scar_sample and (args.scar_poly or args.scar_raster or args.burn_prob):
802        retval["scar"] = build_scars(
803            args.scar_raster,
804            args.scar_poly,
805            args.burn_prob,
806            Path(args.scar_sample),
807            raster_props["RasterXSize"],
808            raster_props["RasterYSize"],
809            raster_props["Transform"],
810            authid,
811        )
812        logger.info("built scars return value: %s (0 means sucess)", retval["scar"])
813
814    if args.stat_sample and args.stat_raster:
815        retval["stat"] = build_stats(
816            args.stat_raster,
817            args.stat_summary,
818            Path(args.stat_sample),
819            raster_props["RasterXSize"],
820            raster_props["RasterYSize"],
821            raster_props["Transform"],
822            authid,
823        )
824        logger.info("built statistic return value: %s (0 means success)", retval["stat"])
825
826    print(retval)
827    return sum(retval.values())
828
829
830if __name__ == "__main__":
831    sys.exit(main(sys.argv))
logger = <Logger fire2a.cell2fire (WARNING)>

captures the local logger

def raster_layer_to_firebreak_csv( layer: qgis._core.QgsRasterLayer, firebreak_val: int = 1, output_file='firebreaks.csv') -> None:
45def raster_layer_to_firebreak_csv(layer: QgsRasterLayer, firebreak_val: int = 1, output_file="firebreaks.csv") -> None:
46    """Write a (Cell2) Fire Simulator (C2FW) firebreak csv file from a QGIS raster layer  
47    Usage as cli argument `Cell2Fire --FirebreakCells firebreaks.csv ...`
48
49    Args:
50    -   layer (QgsRasterLayer): A QGIS raster layer, default is the active layer
51    -   firebreak_val (int): The value used to identify the firebreaks, default is 666
52    -   output_file (str or Path): The path to the output csv file, default is firebreaks.csv
53
54    QGIS Desktop Example: Choose method A or B, draw with serval (2)
55
56    1.A. Use the 'Create constant raster layer' tool to create one with the same extent extent, crs and pixel size than the fuels raster layer. Recommended: 
57    - constant value = 0
58    - (Advanced Parameters) output raster data type = Byte
59
60    1.B. Use the 'raster calculator' with a base layer and 0 in the formula
61
62    2. Use 'Serval' plugin-tool to draw with the mouse the firebreaks on the new raster layer (values =1). Reload or save as to see changes.
63
64    QGIS Python Console Example:  
65    ```
66    layer = iface.activeLayer()  
67    from fire2a.firebreaks import raster_layer_to_firebreak_csv
68    raster_layer_to_firebreak_csv(layer)
69    import processing
70    processing.run("fire2a:cell2firesimulator", ...
71    ```
72    See also: https://fire2a.github.io/docs/docs/qgis-toolbox/c2f_firebreaks.html
73    """  # fmt: skip
74    from numpy import array as np_array
75    from numpy import where as np_where
76
77    from fire2a.raster import get_rlayer_data, xy2id
78
79    width = layer.width()
80    data = get_rlayer_data(layer)
81
82    # numpy is hh,ww indexing
83    yy, xx = np_where(data == firebreak_val)
84    ids = np_array([xy2id(x, y, width) for x, y in zip(xx, yy)])
85    ids += 1
86
87    with open(output_file, "w") as f:
88        f.write("Year,Ncell\n")
89        f.write(f"1,{','.join(map(str,ids))}\n")

Write a (Cell2) Fire Simulator (C2FW) firebreak csv file from a QGIS raster layer
Usage as cli argument Cell2Fire --FirebreakCells firebreaks.csv ...

Args:

  • layer (QgsRasterLayer): A QGIS raster layer, default is the active layer
  • firebreak_val (int): The value used to identify the firebreaks, default is 666
  • output_file (str or Path): The path to the output csv file, default is firebreaks.csv

QGIS Desktop Example: Choose method A or B, draw with serval (2)

1.A. Use the 'Create constant raster layer' tool to create one with the same extent extent, crs and pixel size than the fuels raster layer. Recommended:

  • constant value = 0
  • (Advanced Parameters) output raster data type = Byte

1.B. Use the 'raster calculator' with a base layer and 0 in the formula

  1. Use 'Serval' plugin-tool to draw with the mouse the firebreaks on the new raster layer (values =1). Reload or save as to see changes.

QGIS Python Console Example:

layer = iface.activeLayer()  
from fire2a.firebreaks import raster_layer_to_firebreak_csv
raster_layer_to_firebreak_csv(layer)
import processing
processing.run("fire2a:cell2firesimulator", ...

See also: https://fire2a.github.io/docs/docs/qgis-toolbox/c2f_firebreaks.html

def get_scars_files(sample_file: pathlib.Path):
 92def get_scars_files(sample_file: Path):
 93    """Get sorted lists of (non-empty) files matching the pattern `root/parent(+any digit)/children(+any digit).(any extension)`
 94
 95    Normally used to read Cell2FireW scars `results/Grids/Grids*/ForestGrid*.csv`
 96
 97    Args:
 98    - sample_file (Path): A sample file to extract the name and extension, parent and root directories
 99
100    Returns a tuple:
101    - bool: True if successful, False otherwise.  
102    - str: Error message, if any.  
103    - Path: `root` - all other paths are relative to this and must be used as root/parent or root/child.  
104    - list[Path]: `parents` - sorted list of parent directories.  
105    - list[int]: `parents_ids` - corresponding simulation ids of these parents.  
106    - list[list[Path]]: `children` - sorted list of lists of children files (grouped by simulation)
107    - list[list[int]]: `children_ids` - list of lists of corresponding period ids of each simulation
108
109    Sample Usage:
110    ```python
111    ret_val, msg, root, parent_dirs, parent_ids, children, children_ids = get_scars_files(Path(sample_file))
112    if not ret_val:
113        logger.error(msg)
114    ```
115    """  # fmt: skip
116    from re import search as re_search
117
118    ext = sample_file.suffix
119    if match := re_search(r"(\d+)$", sample_file.stem):
120        num = match.group()
121    else:
122        msg = f"sample_file: {sample_file} does not contain a number at the end"
123        return False, msg, None, None, None, None, None
124    file_name_wo_num = sample_file.stem[: -len(num)]
125    parent = sample_file.absolute().parent
126    root = sample_file.absolute().parent.parent
127    parent = parent.relative_to(root)
128    if match := re_search(r"(\d+)$", parent.name):
129        num = match.group()
130    else:
131        msg = f"sample_file:{sample_file} parent:{parent} does not contain a number at the end"
132        return False, msg, root, None, None, None, None
133
134    parent_wo_num = parent.name[: -len(num)]
135    parent_dirs = []
136    parent_ids = []
137    for par in root.glob(parent_wo_num + "[0-9]*"):
138        if par.is_dir():
139            par = par.relative_to(root)
140            parent_ids += [int(re_search(r"(\d+)$", par.name).group(0))]
141            parent_dirs += [par]
142    adict = dict(zip(parent_dirs, parent_ids))
143    parent_dirs.sort(key=lambda x: adict[x])
144    parent_ids.sort()
145
146    children = []
147    children_ids = []
148    for par in parent_dirs:
149        chl_files = []
150        chl_ids = []
151        for afile in (root / par).glob(file_name_wo_num + "[0-9]*" + ext):
152            if afile.is_file() and afile.stat().st_size > 0:
153                afile = afile.relative_to(root)
154                chl_ids += [int(re_search(r"(\d+)$", afile.stem).group(0))]
155                chl_files += [afile]
156        adict = dict(zip(chl_files, chl_ids))
157        chl_files.sort(key=lambda x: adict[x])
158        chl_ids.sort()
159        children += [chl_files]
160        children_ids += [chl_ids]
161
162    # msg = f"Got {len(parent_dirs)} parent directories with {sum([len(chl) for chl in children])} children files"
163    msg = ""
164    return True, msg, root, parent_dirs, parent_ids, children, children_ids

Get sorted lists of (non-empty) files matching the pattern root/parent(+any digit)/children(+any digit).(any extension)

Normally used to read Cell2FireW scars results/Grids/Grids*/ForestGrid*.csv

Args:

  • sample_file (Path): A sample file to extract the name and extension, parent and root directories

Returns a tuple:

  • bool: True if successful, False otherwise.
  • str: Error message, if any.
  • Path: root - all other paths are relative to this and must be used as root/parent or root/child.
  • list[Path]: parents - sorted list of parent directories.
  • list[int]: parents_ids - corresponding simulation ids of these parents.
  • list[list[Path]]: children - sorted list of lists of children files (grouped by simulation)
  • list[list[int]]: children_ids - list of lists of corresponding period ids of each simulation

Sample Usage:

ret_val, msg, root, parent_dirs, parent_ids, children, children_ids = get_scars_files(Path(sample_file))
if not ret_val:
    logger.error(msg)
def get_scars_indexed(sample_file: pathlib.Path):
167def get_scars_indexed(sample_file: Path):
168    """Get a sorted list of files with the pattern `root/parent(+any digit)/children(+any digit).(any extension)`
169
170    Args:
171    - sample_file (Path): A sample file to extract the extension, children name (wo ending number),  parent (wo ending number) and root directory
172
173    Returns a tuple:
174    - return_value (bool): True if successful, False otherwise
175    - return_message (str): Debug/Error message if any
176    - root (Path): all paths are relative to this and must be used as root/file
177    - parent_wo_num (str): parent name without the ending number
178    - child_wo_num (str): children name without the ending number
179    - extension (str): file extension
180    - files (list[Path]): sorted list of (relative paths) files
181    - indexes (list[Tuple[int,int]]]): list of tuples of simulation and period ids
182
183    Sample Usage
184    ```python
185    retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
186    if not ret_val:
187        logger.error(msg)
188    ```
189    """
190    from os import sep
191    from re import findall as re_findall
192    from re import search as re_search
193
194    from numpy import array as np_array
195    from numpy import fromiter as np_fromiter
196
197    ext = sample_file.suffix
198    if match := re_search(r"(\d+)$", sample_file.stem):
199        num = match.group()
200    else:
201        msg = f"sample_file: {sample_file} does not contain a number at the end"
202        return False, msg, None, None, None, ext, None, None
203    child_wo_num = sample_file.stem[: -len(num)]
204    parent = sample_file.absolute().parent
205    root = sample_file.absolute().parent.parent
206    parent = parent.relative_to(root)
207    if match := re_search(r"(\d+)$", parent.name):
208        num = match.group()
209    else:
210        msg = f"sample_file:{sample_file} parent:{parent} does not contain a number at the end"
211        return False, msg, root, None, child_wo_num, None, None
212
213    parent_wo_num = parent.name[: -len(num)]
214
215    files = np_array(
216        [
217            afile.relative_to(root)
218            for afile in root.glob(parent_wo_num + "[0-9]*" + sep + child_wo_num + "[0-9]*" + ext)
219            if afile.is_file() and afile.stat().st_size > 0
220        ]
221    )
222
223    if sep == "\\":
224        sep = "\\\\"
225    indexes = np_fromiter(
226        # re_findall(parent_wo_num + r"(\d+)" + sep + child_wo_num + r"(\d+)" + ext, " ".join(map(str, files))),
227        re_findall(r"(\d+)" + sep + child_wo_num + r"(\d+)", " ".join(map(str, files))),
228        dtype=[("sim", int), ("per", int)],
229        count=len(files),
230    )
231
232    files = files[indexes.argsort(order=("sim", "per"))]
233    indexes.sort()
234
235    msg = ""
236    # msg = f"Got {len(files)} files\n"
237    # msg += f"{len(np_unique(indexes['sim']))} simulations\n"
238    # msg += f"{len(np_unique(indexes['per']))} different periods"
239
240    return True, msg, root, parent_wo_num, child_wo_num, ext, files, indexes

Get a sorted list of files with the pattern root/parent(+any digit)/children(+any digit).(any extension)

Args:

  • sample_file (Path): A sample file to extract the extension, children name (wo ending number), parent (wo ending number) and root directory

Returns a tuple:

  • return_value (bool): True if successful, False otherwise
  • return_message (str): Debug/Error message if any
  • root (Path): all paths are relative to this and must be used as root/file
  • parent_wo_num (str): parent name without the ending number
  • child_wo_num (str): children name without the ending number
  • extension (str): file extension
  • files (list[Path]): sorted list of (relative paths) files
  • indexes (list[Tuple[int,int]]]): list of tuples of simulation and period ids

Sample Usage

retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
if not ret_val:
    logger.error(msg)
def group_scars(root, parent_wo_num, child_wo_num, ext, files, indexes):
243def group_scars(root, parent_wo_num, child_wo_num, ext, files, indexes):
244    """Group scars files by simulation and period
245
246    Args:
247    - root (Path): root directory
248    - parent_wo_num (str): parent name without the ending number
249    - child_wo_num (str): children name without the ending number
250    - ext (str): file extension
251    - files (list[Path]): list of files
252    - indexes (list[Tuple[int,int]]): list of tuples of simulation and period ids
253
254    Returns:
255    - parent_ids (list[int]): list of simulation ids
256    - parent_dirs (list[Path]): list of parent directories
257    - children_ids (list[Tuple[int,int]]): list of tuples of simulation and period ids
258    - children_files (list[Path]): list of children files
259    - final_scars_ids (list[Tuple[int,int]]): list of tuples of simulation and period ids
260    - final_scars_files (list[Path]): list of final scars files
261
262    Sample:
263    ```python
264    retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
265    if not retval:
266        logger.error(retmsg)
267        sys.exit(1)
268    parent_ids, parent_dirs, children_ids, children_files, final_scars_ids, final_scars_files = group_scars(
269        root, parent_wo_num, child_wo_num, ext, files, indexes
270    )
271    ```
272    """
273    from numpy import unique as np_unique
274    from numpy import where as np_where
275
276    parent_ids = [sim_id for sim_id in np_unique(indexes["sim"])]
277    children_ids = [[(sim_id, per_id) for per_id in indexes["per"][indexes["sim"] == sim_id]] for sim_id in parent_ids]
278    children_files = [[afile for afile in files[np_where(indexes["sim"] == pid)[0]]] for pid in parent_ids]
279
280    final_idx = [np_where(indexes["sim"] == pid)[0][-1] for pid in parent_ids]
281
282    parent_dirs = [afile.parent for afile in files[final_idx]]
283
284    final_scars_files = files[final_idx]
285    final_scars_ids = indexes[final_idx]
286
287    return parent_ids, parent_dirs, children_ids, children_files, final_scars_ids, final_scars_files

Group scars files by simulation and period

Args:

  • root (Path): root directory
  • parent_wo_num (str): parent name without the ending number
  • child_wo_num (str): children name without the ending number
  • ext (str): file extension
  • files (list[Path]): list of files
  • indexes (list[Tuple[int,int]]): list of tuples of simulation and period ids

Returns:

  • parent_ids (list[int]): list of simulation ids
  • parent_dirs (list[Path]): list of parent directories
  • children_ids (list[Tuple[int,int]]): list of tuples of simulation and period ids
  • children_files (list[Path]): list of children files
  • final_scars_ids (list[Tuple[int,int]]): list of tuples of simulation and period ids
  • final_scars_files (list[Path]): list of final scars files

Sample:

retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
if not retval:
    logger.error(retmsg)
    sys.exit(1)
parent_ids, parent_dirs, children_ids, children_files, final_scars_ids, final_scars_files = group_scars(
    root, parent_wo_num, child_wo_num, ext, files, indexes
)
def build_scars( scar_raster: str, scar_poly: str, burn_prob: str, sample_file: pathlib.Path, W: int, H: int, geotransform: tuple, authid: str, callback=None, feedback=None):
290def build_scars(
291    scar_raster: str,
292    scar_poly: str,
293    burn_prob: str,
294    sample_file: Path,
295    W: int,
296    H: int,
297    geotransform: tuple,
298    authid: str,
299    callback=None,
300    feedback=None,
301):
302    """Builds the final scars raster, evolution scars polygons and/or burn probability raster files
303
304    Args:
305    - scar_raster (str): The output file name for the final scars raster
306    - scar_poly (str): The output file name for the evolution scars polygons
307    - burn_prob (str): The output file name for the burn probability raster
308    - sample_file (Path): Any scar sample file usually `results/Grids/Grids1/ForestGrid1.csv`
309    - W (int): Width of the raster
310    - H (int): Height of the raster
311    - geotransform (tuple): The geotransform of the raster
312    - authid (str): The projection of the raster
313    - callback (function): A function to call with the progress percentage
314    - feedback (QgsFeedback): A feedback object
315
316    Returns:
317    - int: 0 if successful, 1 otherwise
318    """
319    from numpy import any as np_any
320    from numpy import float32 as np_float32
321    from numpy import int8 as np_int8
322    from numpy import loadtxt as np_loadtxt
323    from numpy import zeros as np_zeros
324    from osgeo import gdal, ogr, osr
325
326    from fire2a.processing_utils import get_output_raster_format, get_vector_driver_from_filename
327
328    gdal.UseExceptions()
329
330    retval, retmsg, root, parent_wo_num, child_wo_num, ext, files, indexes = get_scars_indexed(sample_file)
331    if not retval:
332        fprint(retmsg, level="error", feedback=feedback, logger=logger)
333        return 1
334    parent_ids, parent_dirs, children_ids, children_files, final_scars_ids, final_scars_files = group_scars(
335        root, parent_wo_num, child_wo_num, ext, files, indexes
336    )
337
338    if burn_prob:
339        burn_prob_arr = np_zeros((H, W), dtype=np_float32)
340    else:
341        burn_prob_arr = None
342
343    if scar_raster:
344        driver_name = get_output_raster_format(scar_raster, feedback=feedback)
345        scar_raster_ds = gdal.GetDriverByName(driver_name).Create(
346            scar_raster, W, H, len(final_scars_ids), gdal.GDT_Byte
347        )
348        scar_raster_ds.SetGeoTransform(geotransform)
349        scar_raster_ds.SetProjection(authid)
350    else:
351        scar_raster_ds = None
352
353    def final_scar_step(i, data, afile, scar_raster, scar_raster_ds, burn_prob, burn_prob_arr, feedback=None):
354        if scar_raster:
355            band = scar_raster_ds.GetRasterBand(i)
356            # band.SetUnitType("burned")
357            if 0 != band.SetNoDataValue(0):
358                fprint(
359                    f"Set NoData failed for Final Scar {i}: {afile}", level="warning", feedback=feedback, logger=logger
360                )
361            if 0 != band.WriteArray(data):
362                fprint(
363                    f"WriteArray failed for Final Scar {i}: {afile}", level="warning", feedback=feedback, logger=logger
364                )
365            if i % 100 == 0:
366                scar_raster_ds.FlushCache()
367        if burn_prob:
368            if np_any(data == -1):
369                mask = data != -1
370                burn_prob_arr[ mask ] += data[ mask ]
371            else:
372                burn_prob_arr += data
373
374    if scar_poly:
375        # raster for each grid
376        src_ds = gdal.GetDriverByName("MEM").Create("", W, H, 1, gdal.GDT_Byte)
377        src_ds.SetGeoTransform(geotransform)
378        src_ds.SetProjection(authid)  # export coords to file
379
380        # datasource for shadow geometry vector layer (polygonize output)
381        ogr_ds = ogr.GetDriverByName("Memory").CreateDataSource("")
382
383        # spatial reference
384        sp_ref = osr.SpatialReference()
385        sp_ref.SetFromUserInput(authid)
386
387        if scar_poly.startswith("memory:"):
388            driver_name = "GPKG"
389            fprint(f"Memory layer, using {driver_name} driver", level="info", feedback=feedback, logger=logger)
390        else:
391            driver_name = get_vector_driver_from_filename(scar_poly)
392            fprint(f"NOT Mem lyr, using {driver_name} driver", level="info", feedback=feedback, logger=logger)
393
394        drv = ogr.GetDriverByName(driver_name)
395        if 0 != drv.DeleteDataSource(scar_poly):
396            fprint(f"Failed to delete {scar_poly}", level="error", feedback=feedback, logger=logger)
397        otrods = drv.CreateDataSource(scar_poly)
398        otrolyr = otrods.CreateLayer("propagation_scars", srs=sp_ref, geom_type=ogr.wkbPolygon)
399        otrolyr.CreateField(ogr.FieldDefn("simulation", ogr.OFTInteger))
400        otrolyr.CreateField(ogr.FieldDefn("time", ogr.OFTInteger))
401        otrolyr.CreateField(ogr.FieldDefn("area", ogr.OFTInteger))
402        otrolyr.CreateField(ogr.FieldDefn("perimeter", ogr.OFTInteger))
403
404        count_evo = 0
405        count_fin = 0
406        total_files = sum(len(files) for files in children_files)
407
408        for sim_id, ids, files in zip(parent_ids, children_ids, children_files):
409            count_fin += 1
410            for (_, per_id), afile in zip(ids, files):
411                count_evo += 1
412
413                try:
414                    data = np_loadtxt(root / afile, delimiter=",", dtype=np_int8)
415                except:
416                    fprint(
417                        f"Error reading {afile}, retrying with nodata = 0",
418                        level="error",
419                        feedback=feedback,
420                        logger=logger,
421                    )
422                    data = loadtxt_nodata(root / afile, delimiter=",", dtype=np_int8, no_data=0)
423
424                if not np_any(data == 1):
425                    fprint(
426                        f"no fire in {afile}, skipping propagation polygon",
427                        level="warning",
428                        feedback=feedback,
429                        logger=logger,
430                    )
431                else:
432                    src_band = src_ds.GetRasterBand(1)
433                    src_band.SetNoDataValue(0)
434                    src_band.WriteArray(data)
435
436                    ogr_layer = ogr_ds.CreateLayer("", srs=sp_ref)
437                    gdal.Polygonize(src_band, src_band, ogr_layer, -1, ["8CONNECTED=8"])
438
439                    feat = ogr_layer.GetNextFeature()
440                    geom = feat.GetGeometryRef()
441                    featureDefn = otrolyr.GetLayerDefn()
442                    feature = ogr.Feature(featureDefn)
443                    feature.SetGeometry(geom)
444                    feature.SetField("simulation", int(sim_id))
445                    feature.SetField("time", int(per_id))
446                    feature.SetField("area", int(geom.GetArea()))
447                    feature.SetField("perimeter", int(geom.Boundary().Length()))
448                    otrolyr.CreateFeature(feature)
449                    if count_evo % 100 == 0:
450                        otrods.FlushCache()
451
452                if callback:
453                    callback(count_evo / len(files) * 100, f"Processed Propagation-Scar {count_evo}/{len(indexes)}")
454                else:
455                    fprint(
456                        f"Processed Propagation-Scar {count_evo}/{len(indexes)}",
457                        level="info",
458                        feedback=feedback,
459                        logger=logger,
460                    )
461
462            if scar_raster or burn_prob:
463                final_scar_step(
464                    count_fin, data, afile, scar_raster, scar_raster_ds, burn_prob, burn_prob_arr, feedback=feedback
465                )
466                if callback:
467                    callback(None, f"Processed +Final-Scar {count_evo}/{len(indexes)}")
468                else:
469                    fprint(
470                        f"Processed Final-Scar {count_fin}/{len(files)}", level="info", feedback=feedback, logger=logger
471                    )
472        # clean up
473        if scar_raster:
474            scar_raster_ds.FlushCache()
475            scar_raster_ds = None
476        otrods.FlushCache()
477        otrods = None
478        # otrolyr.SyncToDisk() CRASHES QGIS
479        # otrolyr.FlushCache()
480        otrolyr = None
481        src_ds.FlushCache()
482        src_ds = None
483    else:
484        # final scar loop
485        count_fin = 0
486        for (sim_id, per_id), afile in zip(final_scars_ids, final_scars_files):
487            count_fin += 1
488            try:
489                data = np_loadtxt(root / afile, delimiter=",", dtype=np_int8)
490            except:
491                fprint(
492                    f"Error reading {afile}, retrying with nodata = 0", level="error", feedback=feedback, logger=logger
493                )
494                data = loadtxt_nodata(root / afile, delimiter=",", dtype=np_int8, no_data=0)
495            if not np_any(data == 1):
496                fprint(f"no fire in Final-Scar {afile}", level="warning", feedback=feedback, logger=logger)
497            final_scar_step(
498                count_fin, data, afile, scar_raster, scar_raster_ds, burn_prob, burn_prob_arr, feedback=feedback
499            )
500            if callback:
501                callback(count_fin / len(final_scars_files) * 100, f"Processed Final-Scar {count_fin}/{len(files)}")
502            else:
503                fprint(f"Processed Final-Scar {count_fin}/{len(files)}", level="info", feedback=feedback, logger=logger)
504        if scar_raster:
505            scar_raster_ds.FlushCache()
506            scar_raster_ds = None
507
508    if burn_prob:
509        driver_name = get_output_raster_format(burn_prob, feedback=feedback)
510        burn_prob_ds = gdal.GetDriverByName(driver_name).Create(burn_prob, W, H, 1, gdal.GDT_Float32)
511        burn_prob_ds.SetGeoTransform(geotransform)
512        burn_prob_ds.SetProjection(authid)
513        band = burn_prob_ds.GetRasterBand(1)
514        # band.SetUnitType("probability")
515        if 0 != band.SetNoDataValue(0):
516            fprint(
517                f"Set NoData failed for Burn Probability {burn_prob}", level="warning", feedback=feedback, logger=logger
518            )
519        if 0 != band.WriteArray(burn_prob_arr / len(final_scars_files)):  # type: ignore
520            fprint(
521                f"WriteArray failed for Burn Probability {burn_prob}", level="warning", feedback=feedback, logger=logger
522            )
523        burn_prob_ds.FlushCache()
524        burn_prob_ds = None
525
526    return 0

Builds the final scars raster, evolution scars polygons and/or burn probability raster files

Args:

  • scar_raster (str): The output file name for the final scars raster
  • scar_poly (str): The output file name for the evolution scars polygons
  • burn_prob (str): The output file name for the burn probability raster
  • sample_file (Path): Any scar sample file usually results/Grids/Grids1/ForestGrid1.csv
  • W (int): Width of the raster
  • H (int): Height of the raster
  • geotransform (tuple): The geotransform of the raster
  • authid (str): The projection of the raster
  • callback (function): A function to call with the progress percentage
  • feedback (QgsFeedback): A feedback object

Returns:

  • int: 0 if successful, 1 otherwise
def glob_numbered_files( sample_file: pathlib.Path) -> tuple[list[pathlib.Path], pathlib.Path, str, str]:
529def glob_numbered_files(sample_file: Path) -> tuple[list[Path], Path, str, str]:
530    """Get a list of files with the same name (+ any digit) and extension and the directory and name of the sample file
531
532    Args:
533    - sample_file (Path): A sample file to extract the extension, name and directory
534      - e.g., results/Intensity/Intensity1.asc, RateOfSpread/ROSFile2.asc, FlameLength/FL.asc, CrownFractionBurn/Cfb.asc, etc.
535
536    Returns a tuple:
537    - files (list[Path]): sorted list of files
538    - adir (Path): directory of the sample file
539    - aname (str): name of the sample file
540    - ext (str): extension of the sample file
541    """
542    from re import search
543
544    ext = sample_file.suffix
545    if match := search(r"(\d+)$", sample_file.stem):
546        num = match.group()
547    else:
548        raise ValueError(f"sample_file: {sample_file} does not contain a number at the end")
549    aname = sample_file.stem[: -len(num)]
550    adir = sample_file.absolute().parent
551    files = []
552    for afile in sorted(adir.glob(aname + "[0-9]*" + ext)):
553        if afile.is_file() and afile.stat().st_size > 0:
554            files += [afile]
555    # QgsMessageLog.logMessage(f"files: {files}, adir: {adir}, aname: {aname}, ext: {ext}", "fire2a", Qgis.Info)
556    return files, adir, aname, ext

Get a list of files with the same name (+ any digit) and extension and the directory and name of the sample file

Args:

  • sample_file (Path): A sample file to extract the extension, name and directory
    • e.g., results/Intensity/Intensity1.asc, RateOfSpread/ROSFile2.asc, FlameLength/FL.asc, CrownFractionBurn/Cfb.asc, etc.

Returns a tuple:

  • files (list[Path]): sorted list of files
  • adir (Path): directory of the sample file
  • aname (str): name of the sample file
  • ext (str): extension of the sample file
def build_stats( stat_raster: str, stat_summary: str, sample_file: pathlib.Path, W: int, H: int, geotransform: tuple, authid: str, callback=None, feedback=None):
559def build_stats(
560    stat_raster: str,
561    stat_summary: str,
562    sample_file: Path,
563    W: int,
564    H: int,
565    geotransform: tuple,
566    authid: str,
567    callback=None,
568    feedback=None,
569):
570    """Builds final statistics raster (1 band per-simulation) and summary raster (2 bands: mean against pixel burn count and stdev against total number of simulations) files
571    """
572    from numpy import float32 as np_float32
573    from numpy import loadtxt as np_loadtxt
574    from numpy import sqrt as np_sqrt
575    from numpy import zeros as np_zeros
576    from osgeo import gdal
577
578    from fire2a.processing_utils import get_output_raster_format
579
580    gdal.UseExceptions()
581
582    files, root, aname, ext = glob_numbered_files(sample_file)
583    num_headers = count_header_lines(files[0], sep=" ", feedback=feedback)
584
585    if stat_raster:
586        driver_name = get_output_raster_format(stat_raster, feedback=feedback)
587        stat_raster_ds = gdal.GetDriverByName(driver_name).Create(stat_raster, W, H, len(files), gdal.GDT_Float32)
588        stat_raster_ds.SetGeoTransform(geotransform)
589        stat_raster_ds.SetProjection(authid)
590    else:
591        stat_raster_ds = None
592
593    if stat_summary:
594        summed = np_zeros((H, W), dtype=np_float32)
595        sumsquared = np_zeros((H, W), dtype=np_float32)
596        burncount = np_zeros((H, W), dtype=np_float32)
597    else:
598        summed = None
599        sumsquared = None
600        burncount = None
601
602    count = 0
603    for afile in files:
604        count += 1
605        try:
606            data = np_loadtxt(root / afile, delimiter=" ", dtype=np_float32, skiprows=num_headers)
607        except Exception as e:
608            fprint(
609                f"Error reading {afile}, retrying with nodata = -9999: {e}",
610                level="error",
611                feedback=feedback,
612                logger=logger,
613            )
614            data = loadtxt_nodata(root / afile, delimiter=" ", dtype=np_float32, skiprows=num_headers, no_data=-9999)
615
616        if stat_raster_ds:
617            band = stat_raster_ds.GetRasterBand(count)
618            if 0 != band.SetNoDataValue(0):
619                fprint(
620                    f"Set NoData failed for Statistic {count}: {afile}",
621                    level="warning",
622                    feedback=feedback,
623                    logger=logger,
624                )
625            if 0 != band.WriteArray(data):
626                fprint(
627                    f"WriteArray failed for Statistic {count}: {afile}",
628                    level="warning",
629                    feedback=feedback,
630                    logger=logger,
631                )
632            if count % 100 == 0:
633                stat_raster_ds.FlushCache()
634
635        if stat_summary:
636            mask = data != -9999
637            tmp = data[mask]
638            summed[mask] += tmp
639            sumsquared[mask] += tmp ** 2
640            burncount[mask & (data != 0)] += 1
641
642        if callback:
643            callback(count / len(files) * 100, f"Processed Statistic {count}/{len(files)}")
644        else:
645            fprint(f"Processed Statistic {count}/{len(files)}", level="info", feedback=feedback, logger=logger)
646
647    if stat_raster_ds:
648        stat_raster_ds.FlushCache()
649        stat_raster_ds = None
650
651    if stat_summary:
652        driver_name = get_output_raster_format(stat_summary, feedback=feedback)
653        stat_summary_ds = gdal.GetDriverByName(driver_name).Create(stat_summary, W, H, 2, gdal.GDT_Float32)
654        stat_summary_ds.SetGeoTransform(geotransform)
655        stat_summary_ds.SetProjection(authid)
656        # mean
657        mask = burncount != 0
658        mean = np_zeros((H, W), dtype=np_float32) - 9999
659        mean[mask] = summed[mask] / burncount[mask]
660        band = stat_summary_ds.GetRasterBand(1)
661        if 0 != band.SetNoDataValue(-9999):
662            fprint(
663                f"Set NoData failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
664            )
665        if 0 != band.WriteArray(mean):
666            fprint(
667                f"WriteArray failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
668            )
669        # std
670        # from all simulations
671        N = len(files)
672        stddev = np_sqrt(sumsquared / N - (summed/N)**2)
673        # beacause this is always zero:
674        # stddev = np_zeros((H, W), dtype=np_float32) - 9999
675        # zero_mask = burncount == 0
676        # burncount[zero_mask] = 1
677        # stddev = np_sqrt(sumsquared / burncount - mean ** 2)
678        # stddev[~mask] = -9999
679        band = stat_summary_ds.GetRasterBand(2)
680        if 0 != band.SetNoDataValue(-9999):
681            fprint(
682                f"Set NoData failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
683            )
684        if 0 != band.WriteArray(stddev):
685            fprint(
686                f"WriteArray failed for Statistic {count}: {afile}", level="warning", feedback=feedback, logger=logger
687            )
688        stat_summary_ds.FlushCache()
689        stat_summary_ds = None
690
691    return 0

Builds final statistics raster (1 band per-simulation) and summary raster (2 bands: mean against pixel burn count and stdev against total number of simulations) files

def arg_parser(argv):
694def arg_parser(argv):
695    """Arguments parses to coordinate: scar and stats processing, verbosity, logfile
696
697    Args:
698    - argv (list): list of strings
699      - e.g., interactive: `args = arg_parser(['-vvv'])`
700
701    Returns:
702    - argparse.Namespace: parsed arguments
703    """
704    import argparse
705
706    parser = argparse.ArgumentParser(
707        description=r"Fire2a-Cell2FireW Related algorithms CLI. Implemented here: Cell2FireW plain scars and statistic outputs into rasters and polygons.",
708        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
709    )
710
711    scars = parser.add_argument_group(
712        "Scars: Transform C2FW scar outputs into rasters and polygons, i.e.: results/Grids/Grids*/ForestGrid*.csv"
713    )
714    scars.add_argument(
715        "--scar-sample",
716        help="Matching the pattern 'root/parent(+any digit)/children(+any digit).(any csv extension)' ",
717    )
718    scars.add_argument(
719        "--scar-raster", default="", help="Output file name for the final scars raster, each band a simulation"
720    )
721    scars.add_argument(
722        "--scar-poly",
723        default="",
724        help="Output file name for the evolution scars polygons, multiple features with simulation and period attributes",
725    )
726    scars.add_argument("--burn-prob", default="", help="Output file name for the burn probability raster, one band")
727
728    stats = parser.add_argument_group(
729        "Stats: Transform C2FW outputs into rasters, .e.g.: results/Intensity/Intensity1.asc, RateOfSpread/ROSFile2.asc, FlameLength/FL.asc, CrownFractionBurn/Cfb.asc, etc."
730    )
731    stats.add_argument(
732        "--stat-sample",
733        default="",
734        help="Matching the pattern 'statistic_name(+any digit).asc' ",
735    )
736    stats.add_argument(
737        "--stat-raster", default="", help="Output file name for the statistic raster, each band a simulation"
738    )
739    stats.add_argument(
740        "--stat-summary", default="", help="Output file name for the raster, two bands: mean and std-deviation"
741    )
742
743    parser.add_argument("--verbose", "-v", action="count", default=0, help="WARNING:1, INFO:2, DEBUG:3")
744    parser.add_argument(
745        "--logfile",
746        "-l",
747        action="store_true",
748        help="enable 5 log files named " + NAME + ".log (verbose must be enabled)",
749        default=None,
750    )
751    parser.add_argument(
752        "--base-raster", required=True, help="Raster to base the geotransform, width, heigth and authid"
753    )
754    parser.add_argument(
755        "--authid", required=False, help="Auth id to override (or missing on the base raster (e.g., using .asc raster)"
756    )
757    args = parser.parse_args(argv)
758    if args.logfile:
759        args.logfile = NAME + ".log"
760    return args

Arguments parses to coordinate: scar and stats processing, verbosity, logfile

Args:

  • argv (list): list of strings
    • e.g., interactive: args = arg_parser(['-vvv'])

Returns:

  • argparse.Namespace: parsed arguments
def main(argv=None):
763def main(argv=None):
764    """
765
766    args = arg_parser(['-vvv'])
767    """
768
769    if argv is sys.argv:
770        argv = sys.argv[1:]
771    args = arg_parser(argv)
772
773    if args.verbose != 0:
774        global logger
775        from fire2a import setup_logger
776
777        logger = setup_logger(verbosity=args.verbose, logfile=args.logfile)
778        # set other modules logging level
779        logging.getLogger("asyncio").setLevel(logging.INFO)
780
781    logger.info("args %s", args)
782
783    if not args.base_raster:
784        logger.error("No base raster file name provided")
785        return 1
786
787    if not Path(args.base_raster).is_file():
788        logger.error("Base raster %s is not a file", args.base_raster)
789        return 1
790
791    from fire2a.raster import read_raster
792
793    _, raster_props = read_raster(args.base_raster, data=False, info=True)
794
795    if not (authid := raster_props["Projection"]):
796        if not (authid := args.authid):
797            logger.error("No authid found on the base raster or provided")
798            return 1
799    logger.info("Read base raster, using authid: %s", authid)
800
801    retval = {}
802    if args.scar_sample and (args.scar_poly or args.scar_raster or args.burn_prob):
803        retval["scar"] = build_scars(
804            args.scar_raster,
805            args.scar_poly,
806            args.burn_prob,
807            Path(args.scar_sample),
808            raster_props["RasterXSize"],
809            raster_props["RasterYSize"],
810            raster_props["Transform"],
811            authid,
812        )
813        logger.info("built scars return value: %s (0 means sucess)", retval["scar"])
814
815    if args.stat_sample and args.stat_raster:
816        retval["stat"] = build_stats(
817            args.stat_raster,
818            args.stat_summary,
819            Path(args.stat_sample),
820            raster_props["RasterXSize"],
821            raster_props["RasterYSize"],
822            raster_props["Transform"],
823            authid,
824        )
825        logger.info("built statistic return value: %s (0 means success)", retval["stat"])
826
827    print(retval)
828    return sum(retval.values())

args = arg_parser(['-vvv'])