diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 8d12b81db..be9b9894d 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -59,6 +59,11 @@ "index_list": "0-1", "run": false }, + { + "category": "fire", + "index_list": "0", + "run": false + }, { "category": "land_surface", "index_list": "0", diff --git a/docs/Contributors_Guide/add_use_case.rst b/docs/Contributors_Guide/add_use_case.rst index 5f61469bc..19fa392cb 100644 --- a/docs/Contributors_Guide/add_use_case.rst +++ b/docs/Contributors_Guide/add_use_case.rst @@ -48,6 +48,7 @@ one of the following: * climate * clouds * data_assimilation +* fire * extremes * land_surface * marine_and_cryosphere diff --git a/docs/Users_Guide/quicksearch.rst b/docs/Users_Guide/quicksearch.rst index c51d262d3..80e1aef69 100644 --- a/docs/Users_Guide/quicksearch.rst +++ b/docs/Users_Guide/quicksearch.rst @@ -76,14 +76,15 @@ Use Cases by Application: | `Air Quality and Composition <../search.html?q=AirQualityAndCompAppUseCase&check_keywords=yes&area=default>`_ | `Climate <../search.html?q=ClimateAppUseCase&check_keywords=yes&area=default>`_ | `Clouds <../search.html?q=CloudsAppUseCase&check_keywords=yes&area=default>`_ - | `Short Range <../search.html?q=ShortRangeAppUseCase&check_keywords=yes&area=default>`_ | `Data Assimilation <../search.html?q=DataAssimilationAppUseCase&check_keywords=yes&area=default>`_ | `Ensemble <../search.html?q=EnsembleAppUseCase&check_keywords=yes&area=default>`_ + | `Fire <../search.html?q=FireAppUseCase&check_keywords=yes&area=default>`_ | `Land Surface <../search.html?q=LandSurfaceAppUseCase&check_keywords=yes&area=default>`_ | `Marine and Cryosphere <../search.html?q=MarineAndCryosphereAppUseCase&check_keywords=yes&area=default>`_ | `Medium Range <../search.html?q=MediumRangeAppUseCase&check_keywords=yes&area=default>`_ | `PBL <../search.html?q=PBLAppUseCase&check_keywords=yes&area=default>`_ | `Precipitation <../search.html?q=PrecipitationAppUseCase&check_keywords=yes&area=default>`_ + | `Short Range <../search.html?q=ShortRangeAppUseCase&check_keywords=yes&area=default>`_ | `Space Weather <../search.html?q=SpaceWeatherAppUseCase&check_keywords=yes&area=default>`_ | `Subseasonal to Seasonal <../search.html?q=S2SAppUseCase&check_keywords=yes&area=default>`_ | `Subseasonal to Seasonal: Madden-Julian Oscillation <../search.html?q=S2SMJOAppUseCase&check_keywords=yes&area=default>`_ @@ -95,14 +96,15 @@ Use Cases by Application: | **Air Quality and Composition**: *AirQualityAndCompAppUseCase* | **Climate**: *ClimateAppUseCase* | **Clouds**: *CloudsAppUseCase* - | **Short Range**: *ShortRangeAppUseCase* | **Data Assimilation**: *DataAssimilationAppUseCase* | **Ensemble**: *EnsembleAppUseCase* + | **Fire**: *FireAppUseCase* | **LandSurface**: *LandSurfaceAppUseCase* | **Marine and Cryosphere**: *MarineAndCryosphereAppUseCase* | **Medium Range**: *MediumRangeAppUseCase* | **PBL**: *PBLAppUseCase* | **Precipitation**: *PrecipitationAppUseCase* + | **Short Range**: *ShortRangeAppUseCase* | **Space Weather**: *SpaceWeatherAppUseCase* | **Subseasonal to Seasonal**: *S2SAppUseCase* | **Subseasonal to Seasonal: Madden-Julian Oscillation**: *S2SMJOAppUseCase* diff --git a/docs/_static/fire-GridStat_fcstWRF_obsMMA_fire_perimeter.png b/docs/_static/fire-GridStat_fcstWRF_obsMMA_fire_perimeter.png new file mode 100644 index 000000000..6382e4164 Binary files /dev/null and b/docs/_static/fire-GridStat_fcstWRF_obsMMA_fire_perimeter.png differ diff --git a/docs/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.py b/docs/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.py new file mode 100644 index 000000000..0d48b6d5a --- /dev/null +++ b/docs/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.py @@ -0,0 +1,189 @@ +""" +GridStat: WRF and MMA Fire Perimeter +==================================== + +model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf + +""" +############################################################################## +# .. contents:: +# :depth: 1 +# :local: +# :backlinks: none + +############################################################################## +# Scientific Objective +# -------------------- +# +# This use case demonstrates the use of GridStat to evaluate the performance of the fire spread forecast from the +# WRF-Fire model for the 416 fire in Colorado in 2018. +# There are four fire perimeter observations available from the Multimission Aircraft (MMA). +# The use case uses Python embedding to ingest the WRF-Fire forecast files and convert the MMA observations from +# kml format into a netCDF file METplus can ingest via GenVxMask. +# The output provides contingency statistics of the forecast performance relative to the observed fire. + +############################################################################## +# Version Added +# ------------- +# +# METplus version 6.0 + +############################################################################## +# Datasets +# -------- +# +# +# **Forecast:** WRF Fire +# +# **Observations:** Multimission Aircraft (MMA) +# +# **Location:** All of the input data required for this use case can be +# found in a sample data tarball. Each use case category will have +# one or more sample data tarballs. It is only necessary to download +# the tarball with the use case’s dataset and not the entire collection +# of sample data. Click here to access the METplus releases page and download sample data +# for the appropriate release: https://github.com/dtcenter/METplus/releases +# This tarball should be unpacked into the directory that you will +# set the value of INPUT_BASE. See :ref:`running-metplus` section for more information. +# + + +############################################################################## +# METplus Components +# ------------------ +# +# This use case uses the UserScript wrapper to run a Python script to that +# converts KML fire perimeter files to the poly line format that can be read by +# MET. Then it runs GenVxMask to create gridded MET NetCDF files from the poly +# files. Then it runs GridStat to process the WRF fire forecast files and the +# observation mask files. +# + +############################################################################## +# METplus Workflow +# ---------------- +# +# **Beginning time (INIT_BEG):** 2018-06-01 at 16Z +# +# **End time (INIT_END):** 2018-06-01 at 16Z +# +# **Increment between beginning and end times (INIT_INCREMENT):** None +# +# **Sequence of forecast leads to process (LEAD_SEQ):** 4 hour, 23 hour, 32 hour +# +# This use case processes 3 forecast leads initialized at 16Z on June 1, 2018. +# + + +############################################################################## +# METplus Configuration +# --------------------- +# +# METplus first loads the default configuration file, +# then it loads any configuration files passed to METplus via the command line +# e.g. parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf +# + +############################################################################## +# MET Configuration +# ----------------- +# +# METplus sets environment variables based on user settings in the METplus +# configuration file. See :ref:`How METplus controls MET config file settings` for more details. +# +# **YOU SHOULD NOT SET ANY OF THESE ENVIRONMENT VARIABLES YOURSELF! THEY WILL BE OVERWRITTEN BY METPLUS WHEN IT CALLS THE MET TOOLS!** +# +# If there is a setting in the MET configuration file that is currently +# not supported by METplus you’d like to control, please refer to: +# :ref:`Overriding Unsupported MET config file settings` +# +# .. dropdown:: GridStatConfig_wrapped +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/met_config/GridStatConfig_wrapped +# + +############################################################################## +# Python Embedding +# ---------------- +# +# This use case uses a Python embedding script to read the WRF fire forecast into GridStat. +# +# .. dropdown:: parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/read_wrfout_fire.py +# +# .. highlight:: python +# .. literalinclude:: ../../../../parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/read_wrfout_fire.py +# + +############################################################################## +# User Scripting +# -------------- +# +# This use case calls a Python script to read MMA fire perimeter .kml files +# and convert them into a poly line file that can be read by GenVxMask: +# +# .. dropdown:: parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/find_and_read_fire_perim_poly.py +# +# .. highlight:: python +# .. literalinclude:: ../../../../parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/find_and_read_fire_perim_poly.py +# + + + +############################################################################## +# Running METplus +# --------------- +# +# Pass the use case configuration file to the run_metplus.py script +# along with any user-specific system configuration files if desired:: +# +# run_metplus.py /path/to/METplus/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf /path/to/user_system.conf +# +# See :ref:`running-metplus` for more information. +# + +############################################################################## +# Expected Output +# --------------- +# +# A successful run will output the following both to the screen and to the logfile:: +# +# INFO: METplus has successfully finished running. +# +# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. +# +# * poly/fire_perim_20180601_20.poly +# * poly/fire_perim_20180602_15.poly +# * poly/fire_perim_20180603_00.poly +# * mask/fire_perim_20180601_20_mask.nc +# * mask/fire_perim_20180602_15_mask.nc +# * mask/fire_perim_20180603_00_mask.nc +# * grid_stat/2018060120/grid_stat_040000L_20180601_200000V.stat +# * grid_stat/2018060120/grid_stat_040000L_20180601_200000V_pairs.nc +# * grid_stat/2018060215/grid_stat_230000L_20180602_150000V.stat +# * grid_stat/2018060215/grid_stat_230000L_20180602_150000V_pairs.nc +# * grid_stat/2018060300/grid_stat_320000L_20180603_000000V.stat +# * grid_stat/2018060300/grid_stat_320000L_20180603_000000V_pairs.nc +# + +############################################################################## +# Keywords +# -------- +# +# .. note:: +# +# * UserScriptUseCase +# * GenVxMaskToolUseCase +# * GridStatToolUseCase +# * PythonEmbeddingFileUseCase +# * GRIB2FileUseCase +# * FireAppUseCase +# +# Navigate to the :ref:`quick-search` page to discover other similar use cases. +# +# +# +# sphinx_gallery_thumbnail_path = '_static/fire-GridStat_fcstWRF_obsMMA_fire_perimeter.png' diff --git a/docs/use_cases/model_applications/fire/README.rst b/docs/use_cases/model_applications/fire/README.rst new file mode 100644 index 000000000..c9ec7c6bb --- /dev/null +++ b/docs/use_cases/model_applications/fire/README.rst @@ -0,0 +1,3 @@ +Fire +---- +Verification of fire weather-related atmospheric parameters and fire spread models diff --git a/internal/tests/use_cases/all_use_cases.txt b/internal/tests/use_cases/all_use_cases.txt index ef58c7ff6..1d6443d99 100644 --- a/internal/tests/use_cases/all_use_cases.txt +++ b/internal/tests/use_cases/all_use_cases.txt @@ -88,6 +88,9 @@ Category: data_assimilation 0::StatAnalysis_fcstHAFS_obsPrepBufr_JEDI_IODA_interface::model_applications/data_assimilation/StatAnalysis_fcstHAFS_obsPrepBufr_JEDI_IODA_interface.conf 1::StatAnalysis_fcstGFS_HofX_obsIODAv2_PyEmbed::model_applications/data_assimilation/StatAnalysis_fcstGFS_HofX_obsIODAv2_PyEmbed.conf:: py_embed +Category: fire +0::GridStat_fcstWRF_obsMMA_fire_perimeter::model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf::py_embed + Category: land_surface 0::PointStat_fcstCESM_obsFLUXNET2015_TCI:: model_applications/land_surface/PointStat_fcstCESM_obsFLUXNET2015_TCI.conf:: metplotpy_env, py_embed diff --git a/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf b/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf new file mode 100644 index 000000000..3fc777c74 --- /dev/null +++ b/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter.conf @@ -0,0 +1,103 @@ +[config] + +### +# Processes to run +# https://metplus.readthedocs.io/en/latest/Users_Guide/systemconfiguration.html#process-list +### + +PROCESS_LIST = UserScript, GenVxMask, GridStat + + +### +# Time Info +# LOOP_BY options are INIT, VALID, RETRO, and REALTIME +# If set to INIT or RETRO: +# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set +# If set to VALID or REALTIME: +# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set +# LEAD_SEQ is the list of forecast leads to process +# https://metplus.readthedocs.io/en/latest/Users_Guide/systemconfiguration.html#timing-control +### + +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_FOR_EACH + +LOOP_BY = INIT +INIT_TIME_FMT = %Y%m%d%H +INIT_BEG = 2018060116 +INIT_END = 2018060116 +INIT_INCREMENT = 1d + +LEAD_SEQ = 4H, 23H, 32H + + +### +# File I/O +# https://metplus.readthedocs.io/en/latest/Users_Guide/systemconfiguration.html#directory-and-filename-template-info +### + +SCRIPT_DIR = {PARM_BASE}/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter + +USER_SCRIPT_INPUT_DIR = {INPUT_BASE}/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/mma +USER_SCRIPT_OUTPUT_DIR = {OUTPUT_BASE}/poly + + +GEN_VX_MASK_INPUT_DIR = +GEN_VX_MASK_INPUT_TEMPLATE = "lambert 472 472 37.402645 -107.88144 -107.808 0.02754 6371.229 37.461 N" + +GEN_VX_MASK_INPUT_MASK_DIR = +GEN_VX_MASK_INPUT_MASK_TEMPLATE = {USER_SCRIPT_OUTPUT_DIR}/fire_perim_{valid?fmt=%Y%m%d_%H}.poly + +GEN_VX_MASK_OUTPUT_DIR = +GEN_VX_MASK_OUTPUT_TEMPLATE = {OUTPUT_BASE}/mask/fire_perim_{valid?fmt=%Y%m%d_%H}_mask.nc + +FCST_GRID_STAT_INPUT_DIR = {INPUT_BASE}/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/wrf +FCST_GRID_STAT_INPUT_TEMPLATE = PYTHON_NUMPY + +OBS_GRID_STAT_INPUT_TEMPLATE = {GEN_VX_MASK_OUTPUT_TEMPLATE} + +GRID_STAT_OUTPUT_DIR = {OUTPUT_BASE}/grid_stat +GRID_STAT_OUTPUT_TEMPLATE = {valid?fmt=%Y%m%d%H} + + +### +# UserScript Settings +# https://metplus.readthedocs.io/en/latest/Users_Guide/wrappers.html#userscript +### + +USER_SCRIPT_COMMAND = python3 {SCRIPT_DIR}/find_and_read_fire_perim_poly.py {USER_SCRIPT_INPUT_DIR} {valid?fmt=%Y%m%d%H} {USER_SCRIPT_OUTPUT_DIR} + + +### +# GenVxMask Settings +# https://metplus.readthedocs.io/en/latest/Users_Guide/wrappers.html#genvxmask +### + +GEN_VX_MASK_OPTIONS = -type poly + + +### +# GridStat Settings (optional) +# https://metplus.readthedocs.io/en/latest/Users_Guide/wrappers.html#gridstat +### + +#LOG_GRID_STAT_VERBOSITY = 4 + +MODEL = WRF_FIRE +OBTYPE = FIRE_PERIM_KML + +FCST_VAR1_NAME = {SCRIPT_DIR}/read_wrfout_fire.py {FCST_GRID_STAT_INPUT_DIR}/{init?fmt=%Y%m%d_%H} {valid?fmt=%Y%m%d_%H%M%S} +FCST_VAR1_LEVELS = L0 +FCST_VAR1_THRESH = >0.5 + +OBS_VAR1_NAME = FIRE_PERIM +OBS_VAR1_LEVELS = "*,*" +OBS_VAR1_THRESH = ==1 +OBS_VAR1_OPTIONS = set_attr_valid = "{valid?fmt=%Y%m%d_%H%M%S}" + + +GRID_STAT_REGRID_TO_GRID = FCST + +GRID_STAT_OUTPUT_FLAG_CTC = STAT +GRID_STAT_OUTPUT_FLAG_CTS = STAT + +GRID_STAT_NC_PAIRS_FLAG_LATLON = TRUE diff --git a/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/find_and_read_fire_perim_poly.py b/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/find_and_read_fire_perim_poly.py new file mode 100644 index 000000000..6c348908a --- /dev/null +++ b/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/find_and_read_fire_perim_poly.py @@ -0,0 +1,76 @@ +import sys +import os +from datetime import datetime, timedelta +from glob import glob +import xml.etree.ElementTree as ET + +KML_TEMPLATE = 'N328SF_%m%d%Y%H*.kml' +VALID_FORMAT = '%Y%m%d%H' + +if len(sys.argv) != 4: + print("ERROR: Must supply input directory, valid time (YYYYMMDDHH), and output directory to script") + sys.exit(1) + +# read input directory +input_dir = sys.argv[1] + +# parse valid time +try: + valid_time = datetime.strptime(sys.argv[2], VALID_FORMAT) +except ValueError: + print(f"ERROR: Invalid format for valid time: {sys.argv[2]} (Should match {VALID_FORMAT})") + sys.exit(1) + +# get previous hour valid time +valid_one_hour_ago = valid_time - timedelta(hours=1) + +# build output path +output_dir = sys.argv[3] +output_file = f"fire_perim_{valid_time.strftime('%Y%m%d_%H')}.poly" +output_path = os.path.join(output_dir, output_file) + +# create output directory if it does not exist +os.makedirs(output_dir, exist_ok=True) + +# find input file +input_file = None +input_glob = os.path.join(input_dir, valid_time.strftime(KML_TEMPLATE)) +found_files = glob(input_glob) + +# if no files were found, search one hour ago +if not found_files: + input_glob = os.path.join(input_dir, valid_one_hour_ago.strftime(KML_TEMPLATE)) + found_files = glob(input_glob) + + if not found_files: + print(f"ERROR: Could not find any files for {valid_time} in {input_dir}") + sys.exit(1) + + # if multiple files are found for previous hour, use last file + if len(found_files) > 1: + print(f"WARNING: Found multiple files: {found_files}") + print("Processing the LAST file") + input_file = found_files[-1] + +elif len(found_files) > 1: + print(f"WARNING: Found multiple files: {found_files}") + print("Processing the FIRST file") + +if not input_file: + input_file = found_files[0] + +print(f"Parsing file: {input_file}") + +tree = ET.parse(input_file) +root = tree.getroot() + +search_path = './/{*}coordinates' +coordinates = root.find(search_path).text.split() + +with open(output_path, 'w') as file_handle: + file_handle.write('FIRE_PERIM\n') + for coord in coordinates: + lon, lat, elev = coord.split(',') + file_handle.write(f'{lat} {lon}\n') + +print(f"Wrote output to {output_path}") diff --git a/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/read_wrfout_fire.py b/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/read_wrfout_fire.py new file mode 100644 index 000000000..1505e563a --- /dev/null +++ b/parm/use_cases/model_applications/fire/GridStat_fcstWRF_obsMMA_fire_perimeter/read_wrfout_fire.py @@ -0,0 +1,91 @@ +import sys +import os +from glob import glob +from datetime import datetime + +import xarray as xr + +VAR_NAME = 'FIRE_AREA' +LONG_NAME = 'Fire Area' +FILE_DATE_FORMAT = '%Y-%m-%d_%H:%M:%S' +MET_DATE_FORMAT ='%Y%m%d_%H%M%S' +VALID_FORMAT = '%Y%m%d_%H%M%S' +WRF_FIRE_TEMPLATE = "wrfout_fire_d02_%Y-%m-%d_%H %M %S" +EARTH_RADIUS = 6371.229 + +if len(sys.argv) != 3: + print("ERROR: Must supply input directory and valid time (YYYYMMDDHH) to script") + sys.exit(1) + +# read input directory +input_dir = sys.argv[1] + +# parse valid time +try: + valid_time = datetime.strptime(sys.argv[2], VALID_FORMAT) +except ValueError: + print(f"ERROR: Invalid format for valid time: {sys.argv[2]} (Should match {VALID_FORMAT})") + sys.exit(1) + +# find input file +input_glob = os.path.join(input_dir, valid_time.strftime(WRF_FIRE_TEMPLATE)) +found_files = glob(input_glob) +if not found_files: + print(f"ERROR: Could not find any files for {valid_time} in {input_dir}") + sys.exit(1) + +input_path = found_files[0] + +ds = xr.open_dataset(input_path, decode_times=False) + +valid_dt = datetime.strptime(ds['Times'][0].values.tobytes().decode(), + FILE_DATE_FORMAT) +init_dt = datetime.strptime(ds.attrs['START_DATE'], FILE_DATE_FORMAT) +lead_td = valid_dt - init_dt +lead_hours = lead_td.days * 24 + (lead_td.seconds//3600) +lead_hms = (f"{str(lead_hours).zfill(2)}" + f"{str((lead_td.seconds//60)%60).zfill(2)}00") + +nx = ds.dims['west_east_subgrid'] +ny = ds.dims['south_north_subgrid'] + +d_km = ds.attrs['DX'] * ds.dims['west_east'] / nx / 1000 + +lat_ll = float(ds['FXLAT'][0][0][0]) +lon_ll = float(ds['FXLONG'][0][0][0]) + +met_data = ds[VAR_NAME][0] +met_data = met_data[::-1] +met_data.attrs['valid'] = valid_dt.strftime(MET_DATE_FORMAT) +met_data.attrs['init'] = init_dt.strftime(MET_DATE_FORMAT) +met_data.attrs['lead'] = lead_hms +met_data.attrs['accum'] = '000000' +met_data.attrs['name'] = VAR_NAME +met_data.attrs['long_name'] = LONG_NAME +met_data.attrs['level'] = 'Z0' +met_data.attrs['units'] = '%' +met_data.attrs['grid'] = { + 'type': ds.attrs['MAP_PROJ_CHAR'], + 'hemisphere': 'N' if float(ds.attrs['POLE_LAT']) > 0 else 'S', + 'name': VAR_NAME, + 'nx': nx, + 'ny': ny, + 'lat_pin': lat_ll, + 'lon_pin': lon_ll, + 'x_pin': 0.0, + 'y_pin': 0.0, + 'lon_orient': float(ds.attrs['CEN_LON']), + 'd_km': d_km, + 'r_km': EARTH_RADIUS, + 'scale_lat_1': float(ds.attrs['TRUELAT1']), + 'scale_lat_2': float(ds.attrs['TRUELAT2']), +} + +print(met_data) +for key,value in met_data.attrs.items(): + if key == 'grid': + print(f"{key}:") + for key2,value2 in value.items(): + print(f" {key2}: {value2}") + else: + print(f"{key}: {value}")