diff --git a/CMake/BuildERFExe.cmake b/CMake/BuildERFExe.cmake index 12b5e19ac..38ff74371 100644 --- a/CMake/BuildERFExe.cmake +++ b/CMake/BuildERFExe.cmake @@ -350,6 +350,7 @@ function(build_erf_lib erf_lib_name) ${SRC_DIR}/IO/ERF_Write1DProfiles_stag.cpp ${SRC_DIR}/IO/ERF_WriteScalarProfiles.cpp ${SRC_DIR}/IO/ERF_Plotfile.cpp + ${SRC_DIR}/IO/ERF_Plotfile2DCatalog.cpp ${SRC_DIR}/IO/ERF_Plotfile2D.cpp ${SRC_DIR}/IO/ERF_Plotfile2DUtils.cpp ${SRC_DIR}/IO/ERF_WriteSubvolume.cpp diff --git a/Docs/sphinx_doc/Plotfiles.rst b/Docs/sphinx_doc/Plotfiles.rst index 0c2548abd..cbccc7cba 100644 --- a/Docs/sphinx_doc/Plotfiles.rst +++ b/Docs/sphinx_doc/Plotfiles.rst @@ -750,6 +750,9 @@ concentration. Output Options for 2D Plotfiles ------------------------------- +The table below lists the built-in 2D diagnostic catalog. ERF writes selected +variables in this order. + +--------------------+---------------------------------------------------------------+ | Parameter | Definition | +====================+===============================================================+ diff --git a/Source/ERF.H b/Source/ERF.H index acd2cf14d..d48086217 100644 --- a/Source/ERF.H +++ b/Source/ERF.H @@ -1228,16 +1228,6 @@ private: ,"qsrc_sw", "qsrc_lw" }; - // ************************************************************************************** - // NOTE: The order of variable names here **MUST MATCH THE ORDER** in IO/ERF_Plotfile.cpp - // ************************************************************************************** - const amrex::Vector derived_names_2d { - "z_surf", "landmask", "mapfac", "lat_m", "lon_m", - "u_star", "w_star", "t_star", "q_star", "Olen", "pblh", - "t_surf", "q_surf", "z0", "OLR", "sens_flux", "laten_flux", - "surf_pres", "integrated_qv" - }; - // ************************************************************************************** // NOTE: The order of variable names here **MUST MATCH THE ORDER** in IO/ERF_WriteSubVolume.cpp // ************************************************************************************** diff --git a/Source/IO/ERF_Plotfile2D.cpp b/Source/IO/ERF_Plotfile2D.cpp index 6e27079de..f20e5003b 100644 --- a/Source/IO/ERF_Plotfile2D.cpp +++ b/Source/IO/ERF_Plotfile2D.cpp @@ -1,4 +1,5 @@ #include "ERF.H" +#include "ERF_Plotfile2DCatalog.H" #include "ERF_NCPlotFile.H" #include "ERF_Plotfile2DUtils.H" #include "ERF_EpochTime.H" @@ -16,6 +17,8 @@ namespace // validates the requested names, assembles the slab geometry, fills existing // diagnostics, and dispatches to AMReX or NetCDF output. Nontrivial science // diagnostics should live in dedicated modules and be called from here. +// Keep the fill order below synchronized with plotfile2d::diagnostic_catalog() +// until the fill blocks move into dedicated diagnostic modules. std::string make_2d_plotfile_name (int which, int plot_step, @@ -102,9 +105,12 @@ ERF::setPlotVariables2D (const std::string& pp_plot_var_names, Vector mf_comp++; } // sens_flux + // Keep the legacy output name "laten_flux"; it maps to the vertical + // water-vapor surface flux field. if (containerHasElement(plot_var_names, "laten_flux")) { #ifdef _OPENMP #pragma omp parallel if (amrex::Gpu::notInLaunchRegion()) #endif - if (SFS_hfx3_lev[lev]) { + if (SFS_q1fx3_lev[lev]) { for ( MFIter mfi(mf[lev],TilingIfNotGPU()); mfi.isValid(); ++mfi) { const Box& bx = mfi.tilebox(); diff --git a/Source/IO/ERF_Plotfile2DCatalog.H b/Source/IO/ERF_Plotfile2DCatalog.H new file mode 100644 index 000000000..3de5bfdb9 --- /dev/null +++ b/Source/IO/ERF_Plotfile2DCatalog.H @@ -0,0 +1,69 @@ +#ifndef ERF_PLOTFILE2DCATALOG_H_ +#define ERF_PLOTFILE2DCATALOG_H_ + +#include + +#include + +namespace plotfile2d +{ + +enum class DiagnosticID +{ + ZSurf, + LandMask, + MapFac, + LatM, + LonM, + UStar, + WStar, + TStar, + QStar, + Olen, + Pblh, + TSurf, + QSurf, + Z0, + OLR, + SensFlux, + LatenFlux, + SurfPres, + IntegratedQv +}; + +enum class DiagnosticCategory +{ + Geometry, + SurfaceLayer, + Radiation, + SurfaceFlux, + SurfaceState, + ColumnIntegral +}; + +enum class MissingPolicy +{ + AlwaysAvailable, + FillZeroWhenUnavailable, + FillMinus999WhenUnavailable +}; + +struct DiagnosticDescriptor +{ + DiagnosticID id; + const char* name; + const char* long_name; + const char* units; + DiagnosticCategory category; + MissingPolicy missing_policy; +}; + +const amrex::Vector& diagnostic_catalog (); + +amrex::Vector diagnostic_names (); + +const DiagnosticDescriptor* find_diagnostic (const std::string& name); + +} // namespace plotfile2d + +#endif diff --git a/Source/IO/ERF_Plotfile2DCatalog.cpp b/Source/IO/ERF_Plotfile2DCatalog.cpp new file mode 100644 index 000000000..6ec175b91 --- /dev/null +++ b/Source/IO/ERF_Plotfile2DCatalog.cpp @@ -0,0 +1,77 @@ +#include "ERF_Plotfile2DCatalog.H" + +namespace plotfile2d +{ + +// This catalog is the source of truth for built-in 2D plotfile names and +// metadata. Its order defines the canonical component order used for user +// selection. Keep this order synchronized with the fill blocks in +// ERF_Plotfile2D.cpp until those fill blocks move to dedicated diagnostic +// modules. +// The catalog is metadata only. It must not become a home for diagnostic +// science. + +namespace +{ + +const amrex::Vector& catalog_storage () +{ + static const amrex::Vector catalog{ + {DiagnosticID::ZSurf, "z_surf", "Surface elevation", "m", DiagnosticCategory::Geometry, MissingPolicy::AlwaysAvailable}, + {DiagnosticID::LandMask, "landmask", "Land-sea mask", "1", DiagnosticCategory::Geometry, MissingPolicy::AlwaysAvailable}, + {DiagnosticID::MapFac, "mapfac", "Map factor at mass points", "1", DiagnosticCategory::Geometry, MissingPolicy::AlwaysAvailable}, + {DiagnosticID::LatM, "lat_m", "Latitude at unstaggered mass points", "deg", DiagnosticCategory::Geometry, MissingPolicy::FillZeroWhenUnavailable}, + {DiagnosticID::LonM, "lon_m", "Longitude at unstaggered mass points", "deg", DiagnosticCategory::Geometry, MissingPolicy::FillZeroWhenUnavailable}, + {DiagnosticID::UStar, "u_star", "Friction velocity from the surface layer", "m/s", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::WStar, "w_star", "Convective velocity scale from the surface layer","m/s", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::TStar, "t_star", "Temperature scale from the surface layer", "K", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::QStar, "q_star", "Humidity scale from the surface layer", "kg/kg", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::Olen, "Olen", "Obukhov length from the surface layer", "m", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::Pblh, "pblh", "Planetary boundary layer height", "m", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::TSurf, "t_surf", "Surface temperature from the surface layer", "K", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::QSurf, "q_surf", "Surface humidity from the surface layer", "kg/kg", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::Z0, "z0", "Roughness height from the surface layer", "m", DiagnosticCategory::SurfaceLayer, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::OLR, "OLR", "Outgoing longwave radiation at the model top", "W/m^2", DiagnosticCategory::Radiation, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::SensFlux, "sens_flux", "Surface sensible heat flux", "kg K m^-2 s^-1", DiagnosticCategory::SurfaceFlux, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::LatenFlux, "laten_flux", "Surface moisture flux (legacy output name)", "kg m^-2 s^-1", DiagnosticCategory::SurfaceFlux, MissingPolicy::FillMinus999WhenUnavailable}, + {DiagnosticID::SurfPres, "surf_pres", "Surface pressure", "Pa", DiagnosticCategory::SurfaceState, MissingPolicy::AlwaysAvailable}, + {DiagnosticID::IntegratedQv, "integrated_qv","Column-integrated water vapor", "kg/m^2", DiagnosticCategory::ColumnIntegral, MissingPolicy::FillZeroWhenUnavailable}, + }; + + return catalog; +} + +} // namespace + +const amrex::Vector& +diagnostic_catalog () +{ + return catalog_storage(); +} + +amrex::Vector +diagnostic_names () +{ + amrex::Vector names; + names.reserve(diagnostic_catalog().size()); + + for (const auto& descriptor : diagnostic_catalog()) { + names.push_back(descriptor.name); + } + + return names; +} + +const DiagnosticDescriptor* +find_diagnostic (const std::string& name) +{ + for (const auto& descriptor : diagnostic_catalog()) { + if (name == descriptor.name) { + return &descriptor; + } + } + + return nullptr; +} + +} // namespace plotfile2d diff --git a/Source/IO/Make.package b/Source/IO/Make.package index b8a9cd5bb..287e7e0d9 100644 --- a/Source/IO/Make.package +++ b/Source/IO/Make.package @@ -1,5 +1,6 @@ CEXE_sources += ERF_Plotfile.cpp +CEXE_sources += ERF_Plotfile2DCatalog.cpp CEXE_sources += ERF_Plotfile2D.cpp CEXE_sources += ERF_Plotfile2DUtils.cpp CEXE_sources += ERF_Checkpoint.cpp diff --git a/Tests/Unit/IO/ERF_GTestPlotfile2D.cpp b/Tests/Unit/IO/ERF_GTestPlotfile2D.cpp index b979f4981..4ad39d075 100644 --- a/Tests/Unit/IO/ERF_GTestPlotfile2D.cpp +++ b/Tests/Unit/IO/ERF_GTestPlotfile2D.cpp @@ -1,7 +1,9 @@ #include +#include #include +#include "ERF_Plotfile2DCatalog.H" #include "ERF_Plotfile2DUtils.H" using namespace plotfile2d; @@ -88,6 +90,112 @@ TEST(Plotfile2D, EmptyRequestsReturnEmptyLists) EXPECT_TRUE(selection.unavailable.empty()); } +// Motivation: The catalog order defines the canonical 2D plotfile layout, so +// this test catches accidental reordering before it changes output metadata. +TEST(Plotfile2D, CatalogNamesMatchCanonicalOrder) +{ + const amrex::Vector expected{ + "z_surf", "landmask", "mapfac", "lat_m", "lon_m", + "u_star", "w_star", "t_star", "q_star", "Olen", "pblh", + "t_surf", "q_surf", "z0", "OLR", "sens_flux", "laten_flux", + "surf_pres", "integrated_qv" + }; + + EXPECT_EQ(plotfile2d::diagnostic_names(), expected); +} + +// Motivation: Each built-in 2D diagnostic name must be unique so user input +// maps to one output component and one metadata record. +TEST(Plotfile2D, CatalogNamesAreUnique) +{ + const auto names = plotfile2d::diagnostic_names(); + std::unordered_set unique_names(names.begin(), names.end()); + + EXPECT_EQ(unique_names.size(), names.size()); +} + +// Motivation: Each built-in 2D diagnostic ID must be unique so the catalog can +// stay stable even if a display name changes later. +TEST(Plotfile2D, CatalogIdsAreUnique) +{ + std::unordered_set unique_ids; + for (const auto& descriptor : plotfile2d::diagnostic_catalog()) { + unique_ids.insert(static_cast(descriptor.id)); + } + + EXPECT_EQ(unique_ids.size(), plotfile2d::diagnostic_catalog().size()); +} + +// Motivation: Catalog metadata feeds documentation and future diagnostics, so +// each built-in entry needs a name, long name, units, category, and policy. +TEST(Plotfile2D, CatalogDescriptorsHaveRequiredMetadata) +{ + for (const auto& descriptor : plotfile2d::diagnostic_catalog()) { + EXPECT_NE(descriptor.name, nullptr); + EXPECT_NE(descriptor.long_name, nullptr); + EXPECT_NE(descriptor.units, nullptr); + EXPECT_FALSE(std::string(descriptor.name).empty()); + EXPECT_FALSE(std::string(descriptor.long_name).empty()); + EXPECT_FALSE(std::string(descriptor.units).empty()); + + bool valid_category = false; + switch (descriptor.category) { + case plotfile2d::DiagnosticCategory::Geometry: + case plotfile2d::DiagnosticCategory::SurfaceLayer: + case plotfile2d::DiagnosticCategory::Radiation: + case plotfile2d::DiagnosticCategory::SurfaceFlux: + case plotfile2d::DiagnosticCategory::SurfaceState: + case plotfile2d::DiagnosticCategory::ColumnIntegral: + valid_category = true; + break; + } + EXPECT_TRUE(valid_category); + + bool valid_missing_policy = false; + switch (descriptor.missing_policy) { + case plotfile2d::MissingPolicy::AlwaysAvailable: + case plotfile2d::MissingPolicy::FillZeroWhenUnavailable: + case plotfile2d::MissingPolicy::FillMinus999WhenUnavailable: + valid_missing_policy = true; + break; + } + EXPECT_TRUE(valid_missing_policy); + } +} + +// Motivation: The selection helper should be able to resolve a known catalog +// name back to the descriptor that defines its metadata. +TEST(Plotfile2D, FindDiagnosticReturnsDescriptorForKnownName) +{ + const auto* descriptor = plotfile2d::find_diagnostic("sens_flux"); + + ASSERT_NE(descriptor, nullptr); + EXPECT_STREQ(descriptor->name, "sens_flux"); + EXPECT_EQ(descriptor->id, plotfile2d::DiagnosticID::SensFlux); + EXPECT_EQ(descriptor->category, plotfile2d::DiagnosticCategory::SurfaceFlux); + EXPECT_EQ(descriptor->missing_policy, plotfile2d::MissingPolicy::FillMinus999WhenUnavailable); +} + +// Motivation: Unknown catalog lookups should fail cleanly so callers can +// distinguish a typo from a valid built-in diagnostic. +TEST(Plotfile2D, FindDiagnosticReturnsNullForUnknownName) +{ + EXPECT_EQ(plotfile2d::find_diagnostic("not_a_real_2d_name"), nullptr); +} + +// Motivation: The selection helper must follow the catalog order, not the user +// request order, so equivalent input decks produce the same component layout. +TEST(Plotfile2D, SelectionUsesCatalogCanonicalOrder) +{ + const auto available = plotfile2d::diagnostic_names(); + const amrex::Vector requested{"surf_pres", "z0", "z_surf", "lon_m"}; + + const auto selection = select_requested_plot_variables(requested, available); + + const amrex::Vector expected{"z_surf", "lon_m", "z0", "surf_pres"}; + EXPECT_EQ(selection.accepted, expected); +} + // Motivation: A warning must name both the input parameter and the skipped // variable so the user can fix the correct namelist entry. TEST(Plotfile2D, WarningMessageNamesParameterAndVariable)