From 3754cf94115ad7879a1ee0aeecfd0aac25855d1d Mon Sep 17 00:00:00 2001 From: Gabriele Bozzola Date: Mon, 8 Apr 2024 15:29:07 -0700 Subject: [PATCH 1/2] Add more documentation --- .github/workflows/CI.yml | 7 + .github/workflows/Invalidations.yml | 40 +++ Manifest.toml | 7 +- Project.toml | 10 +- docs/Project.toml | 2 +- docs/src/api.md | 3 +- docs/src/developer_guide.md | 51 +-- docs/src/index.md | 4 +- docs/src/internals.md | 58 +++- docs/src/user_guide.md | 105 +++++- docs/src/writers.md | 42 ++- src/Callbacks.jl | 28 +- src/clima_diagnostics.jl | 138 +++++--- src/hdf5_writer.jl | 30 +- src/netcdf_writer.jl | 521 +++------------------------- src/netcdf_writer_coordinates.jl | 451 ++++++++++++++++++++++++ src/utils.jl | 4 +- test/TestTools.jl | 38 +- test/callback.jl | 8 +- test/diagnostics.jl | 15 - test/integration_test.jl | 121 +++++++ test/runtests.jl | 2 + test/writers.jl | 12 + 23 files changed, 1057 insertions(+), 640 deletions(-) create mode 100644 .github/workflows/Invalidations.yml create mode 100644 src/netcdf_writer_coordinates.jl create mode 100644 test/integration_test.jl diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a896f270..26095b27 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -39,3 +39,10 @@ jobs: files: lcov.info env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Archive production artifacts + uses: actions/upload-artifact@v4 + with: + name: flamegraphs + path: | + test/flame.html + test/allocs.html diff --git a/.github/workflows/Invalidations.yml b/.github/workflows/Invalidations.yml new file mode 100644 index 00000000..881ebecd --- /dev/null +++ b/.github/workflows/Invalidations.yml @@ -0,0 +1,40 @@ +name: Invalidations + +on: + pull_request: + +concurrency: + # Skip intermediate builds: always. + # Cancel intermediate builds: always. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + evaluate: + # Only run on PRs to the default branch. + # In the PR trigger above branches can be specified only explicitly whereas this check should work for master, main, or any other default branch + if: github.base_ref == github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - uses: julia-actions/setup-julia@v1 + with: + version: '1' + - uses: actions/checkout@v4 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-invalidations@v1 + id: invs_pr + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-invalidations@v1 + id: invs_default + + - name: Report invalidation counts + run: | + echo "Invalidations on default branch: ${{ steps.invs_default.outputs.total }} (${{ steps.invs_default.outputs.deps }} via deps)" >> $GITHUB_STEP_SUMMARY + echo "This branch: ${{ steps.invs_pr.outputs.total }} (${{ steps.invs_pr.outputs.deps }} via deps)" >> $GITHUB_STEP_SUMMARY + - name: Check if the PR does increase number of invalidations + if: steps.invs_pr.outputs.total > steps.invs_default.outputs.total + run: exit 1 diff --git a/Manifest.toml b/Manifest.toml index 772d618e..cd91f4cc 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.10.2" manifest_format = "2.0" -project_hash = "b5cbbfc2ad5b08767926a393e2ce43ef22d93177" +project_hash = "24609dacff2a805e1985ee1c50ed6e4a693f4a3e" [[deps.ADTypes]] git-tree-sha1 = "016833eb52ba2d6bea9fcb50ca295980e728ee24" @@ -1140,6 +1140,11 @@ git-tree-sha1 = "6cc9d682755680e0f0be87c56392b7651efc2c7b" uuid = "9602ed7d-8fef-5bc8-8597-8f21381861e8" version = "0.1.5" +[[deps.UnrolledUtilities]] +git-tree-sha1 = "b73f7a7c25a2618c5052c80ed32b07e471cc6cb0" +uuid = "0fe1646c-419e-43be-ac14-22321958931b" +version = "0.1.2" + [[deps.UnsafeAtomics]] git-tree-sha1 = "6331ac3440856ea1988316b46045303bef658278" uuid = "013be700-e6cd-48c3-b4a1-df204f14c38f" diff --git a/Project.toml b/Project.toml index 93d87663..4399b340 100644 --- a/Project.toml +++ b/Project.toml @@ -7,8 +7,10 @@ version = "0.0.3" Accessors = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697" ClimaComms = "3a4d1b5c-c61d-41fd-a00a-5873ba7a1b0d" ClimaCore = "d414da3d-4745-48bb-8d80-42e94e092884" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" NCDatasets = "85f8d34a-cbdd-5861-8df4-14fed0d494ab" SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" +UnrolledUtilities = "0fe1646c-419e-43be-ac14-22321958931b" [compat] Accessors = "0.1" @@ -16,12 +18,16 @@ Aqua = "0.8" ClimaComms = "0.5" ClimaCore = "0.13" ClimaTimeSteppers = "0.7" +Dates = "1" Documenter = "1" JuliaFormatter = "1" NCDatasets = "0.13, 0.14" +Profile = "1" +ProfileCanvas = "0.1" SafeTestsets = "0.1" SciMLBase = "1, 2" Test = "1" +UnrolledUtilities = "0.1" julia = "1.9" [extras] @@ -29,8 +35,10 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" ClimaTimeSteppers = "595c0a79-7f3d-439a-bc5a-b232dc3bde79" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" JuliaFormatter = "98e50ef6-434e-11e9-1051-2b60c6c9e899" +Profile = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79" +ProfileCanvas = "efd6af41-a80b-495e-886c-e51b0c7d77a3" SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "ClimaTimeSteppers", "Documenter", "JuliaFormatter", "SafeTestsets", "Test"] +test = ["Aqua", "ClimaTimeSteppers", "Documenter", "JuliaFormatter", "Profile", "ProfileCanvas", "SafeTestsets", "Test"] diff --git a/docs/Project.toml b/docs/Project.toml index a0cf5837..f26d43dc 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -3,5 +3,5 @@ ClimaDiagnostics = "1ecacbb8-0713-4841-9a07-eb5aa8a2d53f" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" [compat] -ClimaDiagnostics = "0.0.1" +ClimaDiagnostics = "0.0.3" Documenter = "0.27" diff --git a/docs/src/api.md b/docs/src/api.md index 9c7a228d..a586af69 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -3,8 +3,7 @@ ## `ClimaDiagnostics` ```@docs -ClimaDiagnostics.DiagnosticsHandler -ClimaDiagnostics.DiagnosticsCallback +ClimaDiagnostics.IntegratorWithDiagnostics ``` ## `Callbacks` diff --git a/docs/src/developer_guide.md b/docs/src/developer_guide.md index f66ae9d7..4326c8c5 100644 --- a/docs/src/developer_guide.md +++ b/docs/src/developer_guide.md @@ -16,38 +16,23 @@ There are three components needed to add support for `ClimaDiagnostics.jl` in yo ## An example for steps 2. and 3. Let us assume that `scheduled_diagnostics` is the list of `ScheduledDiagnostic`s -obtained from step 1. (more on this later), `Y` is the simulation initial state, -`p`, the cache, `t0` the initial time, and `dt` the timestep. +obtained from step 1. (more on this later), and `integrator` a `SciML` +integrator. -Schematically, what we need to do is +All we need to do to add diagnostics is ```julia -import ClimaDiagnostics: DiagnosticsHandler, DiagnosticsCallback - -# Initialize the diagnostics, can be expensive -diagnostic_handler = ClimaDiagnostics.DiagnosticsHandler( - scheduled_diagnostics, - Y, - p, - t0; - dt, - ) - -# Prepare the SciML callback -diag_cb = ClimaDiagnostics.DiagnosticsCallback(diagnostic_handler) +import ClimaDiagnostics: IntegratorWithDiagnostics -SciMLBase.init(args...; kwargs..., callback = diag_cb) +integrator = IntegratorWithDiagnostics(integrator, scheduled_diagnostics) ``` -with `args` and `kwargs` the argument and keyword argument needed to set up the -target simulation. - -In `DiagnosticsHandler`, `dt` is used exclusively for consistency checks. -Suppose your timestep is `50s` and you request a variable to be output every -`70s`, if you pass `dt`, `DiagnosticsHandler` will catch this and error out. If -you don't pass `dt`, `DiagnosticsHandler` will warn you about that. +Creating an `IntegratorWithDiagnostics` results in calling all the diagnostics +once. Therefore, the compile and runtime of this function can be significant if +you have a large number of diagnostics. -Creating a `DiagnosticsHandler` results in calling all the diagnostics once. -Therefore, the compile and runtime of this function can be significant if you -have a large number of diagnostics. +`IntegratorWithDiagnostics` assumes that the state is in `integrator.u` and the +cache in `integrator.p`. This assumption can be adjusted with keyword arguments. +For instance, if the state is in `integrator.Y`, pass the `state_name = :Y` +argument to `IntegratorWithDiagnostics`. You can learn about what is happening under the hook in the [Internals](@ref) page. @@ -304,14 +289,7 @@ Allowing users to just call `monthly_average("hus", writer, t_start)`. > Note: `ClimaDiagnostics` will probably provided these schedules natively at > some point in the future. -### Level 2: Provide defaults - -Once you built your database of variables, it is a good idea to provide -reasonable defaults too. - -TODO: Fill this - -### Level 3: Provide higher-level interfaces (e.g., YAML) +### Level 2: Provide higher-level interfaces (e.g., YAML) Finally, you can set in place that parses user input (e.g., from command line or text files) into `ScheduledDiagnostics` using the short names in your database. @@ -432,7 +410,6 @@ YAML-specified ones. ## API ```@docs -ClimaDiagnostics.DiagnosticsHandler -ClimaDiagnostics.DiagnosticsCallback +ClimaDiagnostics.IntegratorWithDiagnostics ``` diff --git a/docs/src/index.md b/docs/src/index.md index 41569f07..dcef8a49 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -12,10 +12,12 @@ If you are a user of a package that is already using `ClimaDiagnostics.jl`, you can jump to the User guide page. If you are a developer interested in adding support for `ClimaDiagnostics.jl` in -your package or learning about the internal design of this package, please read the Developer guide page. +your package or learning about the internal design of this package, please read +the Developer guide page. ## Features +- Define diagnostics as function of the integrator state and the cache; - Accumulate diagnostics over period of times with associative binary temporal reductions (eg, averages); - Allow users to define arbitrary new diagnostics; diff --git a/docs/src/internals.md b/docs/src/internals.md index d5c92168..0f8d1143 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -1,7 +1,9 @@ -# Internals +# Some notes about the internals of `ClimaDiagnostics` -There are multiple moving parts to this package. In this page, we describe the -internal design. +There are multiple moving parts to this package. In this page, we provide some +notes about the internal design. This page also aims at _clarifying the whys_, +i.e., explaining why things are the way they are. Learning about that might help +you extend this package further. ## The callback system @@ -10,7 +12,9 @@ the end of integration steps. `ClimaDiagnostics` implements its own system to manage callbacks. The centerpiece of this is the `CallbackOrchestrator`, a master callback that is unconditionally executed at the end of _each_ step. `CallbackOrchestrator` loops over each registered callback function, executing -the ones for which the trigger condition is met. +the ones for which the trigger condition is met. We implement our callback +system because it gives flexibility to do what we want and because the `SciML` +ecosystem can be unnecessarily complex for our use case. The callbacks registered with `CallbackOrchestrator` are [`Callback`](@ref) objects (in the [`Callbacks`](@ref) module). `Callback`s are simple `struct` @@ -29,7 +33,8 @@ struct Callback schedule_func::SCHEDULE end ``` -At the end of each step `CallbackOrchestrator` calls `schedules_func`, when it returns true, it calls `callback_func` too. +At the end of each step `CallbackOrchestrator` calls `schedules_func`, when it +returns true, it calls `callback_func` too. Most often, `schedules_func` are not simple functions, but callable objects (subtypes of `AbstractSchedule`). There are two reasons for this: @@ -42,7 +47,14 @@ An internal detail that is not there is related to names. We define a method for ```julia Base.show(io::IO, schedule::AbstractSchedule) = print(io, short_name(schedule)) ``` -This allows us to set names of `ScheduledDiagnostics` with `"$schedule_func"` in both the case `schedule_func` is a normal function, or an `AbstractSchedule`. +This allows us to set names of `ScheduledDiagnostics` with `"$schedule_func"` in +both the case `schedule_func` is a normal function, or an `AbstractSchedule`. + +> We might want to move the Callback system to ClimaUtilities if it grows +> enough. + +Each scheduled diagnostic is assigned two callbacks: one for compute and one for +output. ## Accumulation @@ -57,14 +69,11 @@ Accumulation is accomplished by the `accumulate!` function. All this function does is applying the binary `reduction_time_func` to the previous accumulated value and the newly computed one and store the output to the accumulator. - After an accumulated variable is output, the accumulator is reset to its natural -state. This is achieved with the `reset_accumulator!` function. - -However, we have to fill the space with something -that does not affect the reduction. This, by definition, is the identity of the -operation. The identity of the operation `+` is `0` because `x + 0 = x` for -every `x`. +state. This is achieved with the `reset_accumulator!` function. However, we have +to fill the space with something that does not affect the reduction. This, by +definition, is the identity of the operation. The identity of the operation `+` +is `0` because `x + 0 = x` for every `x`. We have to know the identity for every operation we want to support. Of course, users are welcome to define their own by adding new methods to @@ -78,9 +87,20 @@ end ``` (Or add this to the `reduction_identities.jl` file.) - -## Add discussion on possible alternative designs - -The current design has at least one limitation: - -All the diagnostics have to be initialized at the very beginning. +### On the design of the `DiagnosticsHandler` + +There are two possible choices for accumulation of variables: each scheduled +diagnostic can carry its accumulator and counters, or all the accumulators and +counters are managed by a single central handler. `ClimaDiagnostics` implements +this second approach. The author of this package has not decided whether this is +a good idea or not. On one side, this allows us to have a concretely typed and +well defined `DiagnosticsHandler` struct. On the other side, it forces us to +initialize all the diagnostics at the very beginning of the simulation. It might +be worth exploring the alternative design where the `ScheduledDiagnostics` get +their storage space the first time they are called. + +Given this restriction, the main entry point for `ClimaDiagnostics` is the +`IntegratorWithDiagnostics` function. This function is a little dissatisfying +because it creates a new integrator obtained by copying all the fields of the +old one and adding the diagnostics (with +[`Accessors`](https://github.com/JuliaObjects/Accessors.jl)). diff --git a/docs/src/user_guide.md b/docs/src/user_guide.md index e46152ff..d97092ad 100644 --- a/docs/src/user_guide.md +++ b/docs/src/user_guide.md @@ -119,7 +119,72 @@ Note that we can have multiple `ScheduledDiagnostic`s for the same `ScheduledDiagnostic`s contain two arguments `compute_schedule_func` and `output_schedule_func` which dictate when the variable should be computed and -when it should be output. +when it should be output. These objects have to be functions that take a single +argument (the integrator) and return a boolean value. + +For example, if we want to call a callback every even step, we could pass +```julia +function compute_every_even(integrator) + return mod(integrator.step, 2) == 0 +end +``` +Schedules can be arbitrary. For example, we might want to compute something if +the value of the variable `var` is greater than 100 anywhere. The relevant +schedule for this would be +```julia +function compute_if_larger_than100(integrator) + return maximum(integrator.u.var) > 100 +end +``` + +Strictly speaking, schedules do not have to functions, but callable objects. For +example, the `compute_every_even` schedule we defined earlier could be written +for a more general divisor +```julia +struct EveryDivisor + divisor::Int +end + +function (schedule::EveryDivisor)(integrator) + return mod(integrator.step, schedule.divisor) == 0 +end + +compute_every_even = EveryDivisor(2) +``` +This gives schedules great flexibility because it allows them to contain a state +that can be changed. + +`ClimaDiagnostics` define an `AbstractSchedule` type to implement generic +schedules following the pattern just illustrated. One of the main roles of +`AbstractSchedule`s is to have meaningful names that can be used in +files/datasets/error messages, and so on. For this reason, `Schedule`s in +`ClimaDiagnostics` define methods for `short_name` and `long_name`. + +If you define your own schedule, you are encouraged to define those methods too. + +Let us see a complete example of a new schedule that returns true when a +variable is greater than a threshold. +```julia +import ClimaDiagnostics + +struct ExceedThresholdSchedule <: ClimaDiagnostics.AbstractSchedule + var::Symbol + threshold::Float64 +end + +function (schedule::ExceedThresholdSchedule)(integrator) + return maximum(getproperty(integrator.u, schedule.var)) > schedule.threshold +end + +function ClimaDiagnostics.Callback.short_name(schedule::ExceedThresholdSchedule) + return "$(schedule.var)_more_than_$(schedule.threshold)" +end + +function ClimaDiagnostics.Callback.long_name(schedule::ExceedThresholdSchedule) + return "when max($(schedule.var)) >= $(schedule.threshold)" +end +``` +Names are not too important, but they should be meaningful to you. ##### Temporal reductions @@ -148,22 +213,28 @@ the accumulated value is written with the `writer` and the state reset to the neutral state. To allow for greater flexibility, `ClimaDiagnostics` also provides the option to -evaluate a function before the output is saved. - -Pseudo code: +evaluate a function before the output is saved. This is the `pre_output_hook!` +function that can be provided when defining a `ScheduledDiagnostic`. The +signature for `pre_output_hook!` has to be `pre_output_hook!(accumulated_value, +counter)`, where `counter` is the number of times the diagnostic was called. +Given this, the arithmetic average is obtained with a `+` time reduction and a +`pre_output_hook! = (acc, counter) -> acc .= acc ./ counter`. Given that +averages are very common operations, `ClimaDiagnostics` directly provides the +`pre_output_hook`. So, to define an average, you can directly import and use +`ClimaDiagnostics.average_pre_output_hook!`. + +The following is a sketch of what happens at the end of each step for each +`ScheduledDiagnostic`: ``` -At the end of every time step: - if compute_schedule_func is true: - out = compute! - if reduction_time_func is not nothing: - accumulated_value = reduction_time_func(accumulated_value, out) - counter += 1 - if output_schedule_func is true: - pre_output_hook(accumulated_value, counter) - dump(accumulated_value) - reset(accumulated_value) - reset(counter) +if compute_schedule_func is true: + out = compute! + if reduction_time_func is not nothing: + accumulated_value = reduction_time_func(accumulated_value, out) + counter += 1 +if output_schedule_func is true: + pre_output_hook(accumulated_value, counter) + dump(accumulated_value) + reset(accumulated_value) + reset(counter) ``` - - diff --git a/docs/src/writers.md b/docs/src/writers.md index 3e920d45..b6b8129f 100644 --- a/docs/src/writers.md +++ b/docs/src/writers.md @@ -1,12 +1,37 @@ # Saving the diagnostics - Do not forget to close your writers to avoid file corruption! ## `NetCDFWriter` +The `NetCDFWriter` resamples the input `Field` to a rectangular grid and saves +the output to a NetCDF file. + +The `NetCDFWriter` relies on the `Remappers` module in `ClimaCore` to +interpolate onto the rectangular grid. Horizontally, this interpolation is a +Lagrange interpolation, vertically, it is a linear. This interpolation is not +conservative. Also note that, the order of vertical interpolation drops to zero +in the first and last vertical elements of each column. + +To create a `NetCDFWriter`, you need to specify the target `ClimaCore` `Space` +and the output directory where the files should be saved. By default, the +`NetCDFWriter` appends to existing files and create new ones if they do not +exist. The `NetCDFWriter` does not overwrite existing data and will error out if +existing data is inconsistent with the new one. + +The output in the `NetCDFWriter` roughly follows the CF conventions. + +Each `ScheduledDiagnostic` is output to a different file with name determined by +calling the `output_short_name` on the `ScheduledDiagnostic`. Typically, these +files have names like `ta_1d_max.nc`, `ha_20s_inst.nc`, et cetera. The files +define their dimensions (`lon`, `lat`, `z`, ...). Time is always the first +dimension is any dataset. + +Variables are saved as datasets with attributes, where the attributes include +`long_name`, `standard_name`, `units`... + ```@docs -ClimaDiagnostics.Writers.NetCDF +ClimaDiagnostics.Writers.NetCDFWriter ClimaDiagnostics.Writers.write_field! Base.close ``` @@ -23,10 +48,17 @@ ClimaDiagnostics.Writers.write_field! ## `HDF5Writer` -The `HDF5Writer` in `ClimaDiagnostics` is currently the least developed one. + The `HDF5Writer` writes the `Field` directly to an HDF5 file in such a way that +it can be later read and imported using the `InputOutput` module in `ClimaCore`. + +The `HDF5Writer` writes one file per variable per timestep. The name of the file +is determined by the `output_short_name` field of the `ScheduledDiagnostic` that +is being output. + +> Note: The `HDF5Writer` in `ClimaDiagnostics` is currently the least developed +> one. If you need this writer, we can expand it. ```@docs -ClimaDiagnostics.Writers.NetCDF +ClimaDiagnostics.Writers.HDF5Writer ClimaDiagnostics.Writers.write_field! -Base.close ``` diff --git a/src/Callbacks.jl b/src/Callbacks.jl index 8775b0ca..f75ec406 100644 --- a/src/Callbacks.jl +++ b/src/Callbacks.jl @@ -1,3 +1,16 @@ +""" +The `Callbacks` module contains infrastructure to manage and run our callback +system independently of SciML. + +We define a `Callback` object that contains the function that to be execute and +a function that determines under which condition such function should be run. +Then, we have `CallbackOrchestrator` that loops over the callbacks at the end of +every step and calls what needs to be called. + +The `Callbacks` module also contains a collection of predefined schedule +functions to implement the most common behaviors (e.g., run the callback every N +steps). +""" module Callbacks import ..seconds_to_str_short, ..seconds_to_str_long @@ -31,11 +44,8 @@ Loop over all the `callbacks`, for each, check it the condition to trigger the c `callbacks` has to be a container of `Callback`s. """ function orchestrate_callbacks(integrator, callbacks) - for callback in callbacks - if callback.schedule_func(integrator) - callback.callback_func(integrator) - end - end + active_callbacks = filter(c -> c.schedule_func(integrator), callbacks) + foreach(c -> c.callback_func(integrator), active_callbacks) return nothing end @@ -108,7 +118,7 @@ end Returns true if `integrator.step` is evenly divided by the divisor. """ -function (schedule::DivisorSchedule)(integrator) +function (schedule::DivisorSchedule)(integrator)::Bool return rem(integrator.step, schedule.divisor) == 0 end @@ -153,7 +163,7 @@ Note, this function performs no checks on whether the step is aligned with `dt` """ struct EveryDtSchedule{T} <: AbstractSchedule """The integrator time the last time this function returned true.""" - t_last::Ref{T} + t_last::Base.RefValue{T} """The interval of time needed to elapse for the next time that this function will return true.""" @@ -164,7 +174,7 @@ struct EveryDtSchedule{T} <: AbstractSchedule True every time the current time is larger than the previous time this schedule was true + dt. """ - function EveryDtSchedule(dt; t_start = zero(dt)) + function EveryDtSchedule(dt::T; t_start::T = zero(dt)) where {T} new{typeof(dt)}(Ref(t_start), dt) end end @@ -175,7 +185,7 @@ end Returns true if `integrator.t >= last_t + dt`, where `last_t` is the last time this function was true and `dt` is the schedule interval time. """ -function (schedule::EveryDtSchedule)(integrator) +function (schedule::EveryDtSchedule)(integrator)::Bool next_t = schedule.t_last[] + schedule.dt # Dealing with floating point precision... if integrator.t > next_t || integrator.t ā‰ˆ next_t diff --git a/src/clima_diagnostics.jl b/src/clima_diagnostics.jl index db71c4b3..5f4e9822 100644 --- a/src/clima_diagnostics.jl +++ b/src/clima_diagnostics.jl @@ -1,6 +1,8 @@ import Accessors import SciMLBase +import UnrolledUtilities + import .Callbacks: Callback, CallbackOrchestrator, DivisorSchedule, EveryDtSchedule import .Writers: write_field!, AbstractWriter @@ -49,28 +51,28 @@ function accumulate!( return nothing end -# When the reduction is nothing, we do not need to accumulate anything -accumulate!(_, _, reduction_time_func::Nothing) = nothing - - function compute_callback!( integrator, - accumulators, + accumulator, storage, - diag, - counters, + counter, compute!, + reduction_time_func, ) compute!(storage, integrator.u, integrator.p, integrator.t) + accumulate!(accumulator, storage, reduction_time_func) + counter[] += 1 + return nothing +end - # accumulator[diag] is not defined for non-reductions - diag_accumulator = get(accumulators, diag, nothing) - - accumulate!(diag_accumulator, storage, diag.reduction_time_func) - counters[diag] += 1 +# For non-time reductions +function compute_callback!(integrator, storage, counter, compute!) + compute!(storage, integrator.u, integrator.p, integrator.t) + counter[] += 1 return nothing end + function output_callback!(integrator, accumulators, storage, diag, counters) # Move accumulated value to storage so that we can output it (for reductions). This # provides a unified interface to pre_output_hook! and output, at the cost of an @@ -105,6 +107,12 @@ function output_callback!(integrator, accumulators, storage, diag, counters) return nothing end +""" + DiagnosticsHandler + +A struct that contains the scheduled diagnostics, ancillary data and areas of memory needed +to store and accumulate results. +""" struct DiagnosticsHandler{SD, STORAGE <: Dict, ACC <: Dict, COUNT <: Dict} """An iterable with the `ScheduledDiagnostic`s that are scheduled.""" scheduled_diagnostics::SD @@ -124,11 +132,15 @@ struct DiagnosticsHandler{SD, STORAGE <: Dict, ACC <: Dict, COUNT <: Dict} end """ + DiagnosticsHandler(scheduled_diagnostics, Y, p, t; dt = nothing) -The `DiagnosticsHandler` initializes the diagnostics by calling the `compute!` -function. +An object to instantiate and manage storage spaces for `ScheduledDiagnostics`. -Note: initializing a `DiagnosticsHandler` can be expensive! +The `DiagnosticsHandler` calls `compute!(nothing, Y, p, t)` for each diagnostic. The result +is used to allocate the areas of memory for storage and accumulation. For diagnostics +without reduction, `write_field!(output_writer, result, diagnostic, Y, p, t)` is called too. + +Note: initializing a `DiagnosticsHandler` can be expensive. Keyword arguments =================== @@ -189,7 +201,7 @@ function DiagnosticsHandler(scheduled_diagnostics, Y, p, t; dt = nothing) end return DiagnosticsHandler( - scheduled_diagnostics, + Tuple(scheduled_diagnostics), storage, accumulators, counters, @@ -206,18 +218,36 @@ function DiagnosticsCallback(diagnostics_handler::DiagnosticsHandler) # TODO: We have two types of callbacks: to compute and accumulate diagnostics, and to # dump them to disk. At the moment, they all end up in the same place, but we might want # to keep them separate - callback_arrays = map(diagnostics_handler.scheduled_diagnostics) do diag - compute_callback = - integrator -> begin - compute_callback!( - integrator, - diagnostics_handler.accumulators, - diagnostics_handler.storage[diag], - diag, - diagnostics_handler.counters, - diag.variable.compute!, - ) - end + + # UnrolledUtilities.unrolled_flatmap helps with type stability + + callbacks = UnrolledUtilities.unrolled_flatmap( + diagnostics_handler.scheduled_diagnostics, + ) do diag + isa_time_reduction = !isnothing(diag.reduction_time_func) + if isa_time_reduction + compute_callback = + integrator -> begin + compute_callback!( + integrator, + diagnostics_handler.accumulators[diag], + diagnostics_handler.storage[diag], + Ref(diagnostics_handler.counters[diag]), + diag.variable.compute!, + diag.reduction_time_func, + ) + end + else + compute_callback = + integrator -> begin + compute_callback!( + integrator, + diagnostics_handler.storage[diag], + Ref(diagnostics_handler.counters[diag]), + diag.variable.compute!, + ) + end + end output_callback = integrator -> begin output_callback!( @@ -228,32 +258,56 @@ function DiagnosticsCallback(diagnostics_handler::DiagnosticsHandler) diagnostics_handler.counters, ) end - [ + ( Callback(compute_callback, diag.compute_schedule_func), Callback(output_callback, diag.output_schedule_func), - ] + ) end - return CallbackOrchestrator(vcat(callback_arrays...)) + return CallbackOrchestrator(callbacks) end """ + IntegratorWithDiagnostics(integrator, + scheduled_diagnostics; + state_name = :u, + cache_name = :p) -Add string +Return a new `integrator` with diagnostics defined by `scheduled_diagnostics`. + +`IntegratorWithDiagnostics` is conceptually similar to defining a `DiagnosticsHandler`, +constructing its associated `DiagnosticsCallback`, and adding such callback to a given +integrator. + +The new integrator is identical to the previous one with the only difference that it has a +new callback called after all the other callbacks to accumulate/output diagnostics. + +`IntegratorWithDiagnostics` ensures that the diagnostic callbacks are initialized and called +after everything else is initialized and computed. + +`IntegratorWithDiagnostics` assumes that the state is `integrator.u` and the cache is +`integrator.p`. This behavior can be customized by passing the `state_name` and `cache_name` +keyword arguments. """ -function IntegratorWithDiagnostics(integrator, - scheduled_diagnostics; - state_name = :u, - cache_name = :p) - - diagnostics_handler = DiagnosticsHandler(scheduled_diagnostics, - getproperty(integrator, state_name), - getproperty(integrator, cache_name), - integrator.t; integrator.dt) +function IntegratorWithDiagnostics( + integrator, + scheduled_diagnostics; + state_name::Symbol = :u, + cache_name::Symbol = :p, +) + + diagnostics_handler = DiagnosticsHandler( + scheduled_diagnostics, + getproperty(integrator, state_name), + getproperty(integrator, cache_name), + integrator.t; + integrator.dt, + ) diagnostics_callback = DiagnosticsCallback(diagnostics_handler) continuous_callbacks = integrator.callback.continuous_callbacks - discrete_callbacks = (integrator.callback.discrete_callbacks..., diagnostics_callback) + discrete_callbacks = + (integrator.callback.discrete_callbacks..., diagnostics_callback) callback = SciMLBase.CallbackSet(continuous_callbacks, discrete_callbacks) Accessors.@reset integrator.callback = callback diff --git a/src/hdf5_writer.jl b/src/hdf5_writer.jl index 607e0dcf..209d3c78 100644 --- a/src/hdf5_writer.jl +++ b/src/hdf5_writer.jl @@ -2,24 +2,11 @@ import ClimaComms import ClimaCore.InputOutput """ - HDF5Writer() + HDF5Writer(output_dir) -Save a `ScheduledDiagnostic` to a HDF5 file inside the `output_dir` of the simulation. +Save a `ScheduledDiagnostic` to a HDF5 file inside the `output_dir`. TODO: This is a very barebone HDF5Writer! - -We need to implement the following features/options: -- Toggle for write new files/append -- Checks for existing files -- Check for new subfolders that have to be created -- More meaningful naming conventions (keeping in mind that we can have multiple variables - with different reductions) -- All variables in one file/each variable in its own file -- All timesteps in one file/each timestep in its own file -- Writing the correct attributes -- Overriding simulation.output_dir (e.g., if the path starts with /) -- ...more features/options - """ struct HDF5Writer <: AbstractWriter output_dir::String @@ -32,6 +19,16 @@ Close all the files open in `writer`. (Currently no-op.) """ Base.close(writer::HDF5Writer) = nothing +""" + HDF5Writer(output_dir) + +Save a `ScheduledDiagnostic` to a HDF5 file inside the `output_dir`. + +The name of the file is determined by the `output_short_name` of the output +`ScheduledDiagnostic`. New files are created for each timestep. + +`Field`s can be read back using the `InputOutput` module in `ClimaCore`. +""" function write_field!(writer::HDF5Writer, field, diagnostic, u, p, t) var = diagnostic.variable time = t @@ -41,11 +38,12 @@ function write_field!(writer::HDF5Writer, field, diagnostic, u, p, t) "$(diagnostic.output_short_name)_$(time).h5", ) - comms_ctx = ClimaComms.context(u.c) + comms_ctx = ClimaComms.context(field) hdfwriter = InputOutput.HDF5Writer(output_path, comms_ctx) InputOutput.write!(hdfwriter, field, "$(diagnostic.output_short_name)") attributes = Dict( "time" => time, + "short_name" => diagnostic.output_short_name, "long_name" => diagnostic.output_long_name, "variable_units" => var.units, "standard_variable_name" => var.standard_name, diff --git a/src/netcdf_writer.jl b/src/netcdf_writer.jl index 96dbc276..b95d00aa 100644 --- a/src/netcdf_writer.jl +++ b/src/netcdf_writer.jl @@ -1,468 +1,21 @@ -# netcdf_writer.jl -# -# The flow of the code is: -# -# - We define a generic NetCDF struct with the NetCDF constructor. In doing this, we check -# what we have to do for topography. We might have to interpolate it or not so that we can -# later provide the correct elevation profile. - -# - The first time write_fields! is called with the writer on a new field, we add the -# dimensions to the NetCDF file. This requires understanding what coordinates we have to -# interpolate on and potentially handle topography. -# -# The functions to do so are for the most part all methods of the same functions, so that -# we can achieve the same behavior for different types of configurations (planes/spheres, -# ...), but also different types of fields (horizontal ones, 3D ones, ...) -# +import Dates import ClimaCore: Domains, Geometry, Grids, Fields, Meshes, Spaces import ClimaCore.Remapping: Remapper, interpolate, interpolate! import NCDatasets -""" - add_dimension!(nc::NCDatasets.NCDataset, - name::String, - points; - kwargs...) - - -Add dimension identified by `name` in the given `nc` file and fill it with the given -`points`. -""" -function add_dimension!( - nc::NCDatasets.NCDataset, - name::String, - points; - kwargs..., -) - FT = eltype(points) - - NCDatasets.defDim(nc, name, size(points)[end]) - - dim = NCDatasets.defVar(nc, name, FT, (name,)) - for (k, v) in kwargs - dim.attrib[String(k)] = v - end - - dim[:] = points - - return nothing -end - -function dimension_exists( - nc::NCDatasets.NCDataset, - name::String, - expected_size::Tuple, -) - if haskey(nc, name) - if size(nc[name]) != expected_size - error("Incompatible $name dimension already exists in file") - else - return true - end - else - return false - end -end - -""" - add_time_maybe!(nc::NCDatasets.NCDataset, - float_type::DataType; - kwargs...) - - -Add the `time` dimension (with infinite size) to the given NetCDF file if not already there. -Optionally, add all the keyword arguments as attributes. -""" -function add_time_maybe!( - nc::NCDatasets.NCDataset, - float_type::DataType; - kwargs..., -) - - # If we already have time, do nothing - haskey(nc, "time") && return nothing - - NCDatasets.defDim(nc, "time", Inf) - dim = NCDatasets.defVar(nc, "time", float_type, ("time",)) - for (k, v) in kwargs - dim.attrib[String(k)] = v - end - return nothing -end - -""" - add_space_coordinates_maybe!(nc::NCDatasets.NCDataset, - space::Spaces.AbstractSpace, - num_points; - names) - -Add dimensions relevant to the `space` to the given `nc` NetCDF file. The range is -automatically determined and the number of points is set with `num_points`, which has to be -an iterable of size N, where N is the number of dimensions of the space. For instance, 3 for -a cubed sphere, 2 for a surface, 1 for a column. - -The function returns an array with the names of the relevant dimensions. (We want arrays -because we want to preserve the order to match the one in num_points). - -In some cases, the names are adjustable passing the keyword `names`. -""" -function add_space_coordinates_maybe! end - -""" - target_coordinates!(space::Spaces.AbstractSpace, - num_points) - -Return the range of interpolation coordinates. The range is automatically determined and the -number of points is set with `num_points`, which has to be an iterable of size N, where N is -the number of dimensions of the space. For instance, 3 for a cubed sphere, 2 for a surface, -1 for a column. -""" -function target_coordinates(space, num_points) end - -function target_coordinates( - space::S, - num_points, -) where { - S <: - Union{Spaces.CenterFiniteDifferenceSpace, Spaces.FaceFiniteDifferenceSpace}, -} - # Exponentially spaced with base e - # - # We mimic something that looks like pressure levels - # - # p ~ pā‚€ exp(-z/H) - # - # We assume H to be 7000, which is a good scale height for the Earth atmosphere - H_EARTH = 7000 - - num_points_z = num_points[] - FT = Spaces.undertype(space) - topology = Spaces.topology(space) - vert_domain = topology.mesh.domain - z_min, z_max = FT(vert_domain.coord_min.z), FT(vert_domain.coord_max.z) - # We floor z_min to avoid having to deal with the singular value z = 0. - z_min = max(z_min, 100) - exp_z_min = exp(-z_min / H_EARTH) - exp_z_max = exp(-z_max / H_EARTH) - return collect(-H_EARTH * log.(range(exp_z_min, exp_z_max, num_points_z))) -end - -# Column -function add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space::Spaces.FiniteDifferenceSpace, - num_points_z; - names = ("z",), -) - name, _... = names - z_dimension_exists = dimension_exists(nc, name, (num_points_z,)) - if !z_dimension_exists - zpts = target_coordinates(space, num_points_z) - add_dimension!(nc, name, zpts, units = "m", axis = "Z") - end - return [name] -end - -add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space::Spaces.AbstractSpectralElementSpace, - num_points; -) = add_space_coordinates_maybe!( - nc, - space, - num_points, - Meshes.domain(Spaces.topology(space)); -) - - -# For the horizontal space, we also have to look at the domain, so we define another set of -# functions that dispatches over the domain -target_coordinates(space::Spaces.AbstractSpectralElementSpace, num_points) = - target_coordinates(space, num_points, Meshes.domain(Spaces.topology(space))) - -# Box -function target_coordinates( - space::Spaces.SpectralElementSpace2D, - num_points, - domain::Domains.RectangleDomain, -) - num_points_x, num_points_y = num_points - FT = Spaces.undertype(space) - xmin = FT(domain.interval1.coord_min.x) - xmax = FT(domain.interval1.coord_max.x) - ymin = FT(domain.interval2.coord_min.y) - ymax = FT(domain.interval2.coord_max.y) - xpts = collect(range(xmin, xmax, num_points_x)) - ypts = collect(range(ymin, ymax, num_points_y)) - return (xpts, ypts) -end - -# Plane -function target_coordinates( - space::Spaces.SpectralElementSpace1D, - num_points, - domain::Domains.IntervalDomain, -) - num_points_x, _... = num_points - FT = Spaces.undertype(space) - xmin = FT(domain.coord_min.x) - xmax = FT(domain.coord_max.x) - xpts = collect(range(xmin, xmax, num_points_x)) - return (xpts) -end - -# Cubed sphere -function target_coordinates( - space::Spaces.SpectralElementSpace2D, - num_points, - ::Domains.SphereDomain, -) - num_points_long, num_points_lat = num_points - FT = Spaces.undertype(space) - longpts = collect(range(FT(-180), FT(180), num_points_long)) - latpts = collect(range(FT(-90), FT(90), num_points_lat)) - - return (longpts, latpts) -end - -# Box -function add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space::Spaces.SpectralElementSpace2D, - num_points, - ::Domains.RectangleDomain; - names = ("x", "y"), -) - xname, yname = names - num_points_x, num_points_y = num_points - x_dimension_exists = dimension_exists(nc, xname, (num_points_x,)) - y_dimension_exists = dimension_exists(nc, yname, (num_points_y,)) - - if !x_dimension_exists && !y_dimension_exists - xpts, ypts = target_coordinates(space, num_points) - add_dimension!(nc, xname, xpts; units = "m", axis = "X") - add_dimension!(nc, yname, ypts; units = "m", axis = "Y") - end - return [xname, yname] -end - -# Plane -function add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space::Spaces.SpectralElementSpace1D, - num_points, - ::Domains.IntervalDomain; - names = ("x",), -) - xname, _... = names - num_points_x, = num_points - x_dimension_exists = dimension_exists(nc, xname, (num_points_x,)) - - if !x_dimension_exists - xpts = target_coordinates(space, num_points) - add_dimension!(nc, xname, xpts; units = "m", axis = "X") - end - return [xname] -end - -# Cubed sphere -function add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space::Spaces.SpectralElementSpace2D, - num_points, - ::Domains.SphereDomain; - names = ("lon", "lat"), -) - longname, latname = names - num_points_long, num_points_lat = num_points - - long_dimension_exists = dimension_exists(nc, longname, (num_points_long,)) - lat_dimension_exists = dimension_exists(nc, latname, (num_points_lat,)) - - if !long_dimension_exists && !lat_dimension_exists - longpts, latpts = target_coordinates(space, num_points) - add_dimension!( - nc, - longname, - longpts; - units = "degrees_east", - axis = "X", - ) - add_dimension!(nc, latname, latpts; units = "degrees_north", axis = "Y") - end - - return [longname, latname] -end - -# General hybrid space. This calls both the vertical and horizontal add_space_coordinates_maybe! -# and combines the resulting dictionaries -function add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space::Spaces.ExtrudedFiniteDifferenceSpace, - num_points; - interpolated_physical_z = nothing, -) - - hdims_names = vdims_names = [] - - num_points_horiz..., num_points_vertic = num_points - - # Being an Extruded space, we can assume that we have an horizontal and a vertical space. - # We can also assume that the vertical space has dimension 1 - horizontal_space = Spaces.horizontal_space(space) - - hdims_names = - add_space_coordinates_maybe!(nc, horizontal_space, num_points_horiz) - - vertical_space = Spaces.FiniteDifferenceSpace( - Spaces.vertical_topology(space), - Spaces.staggering(space), - ) - - if Spaces.grid(space).hypsography isa Grids.Flat - vdims_names = - add_space_coordinates_maybe!(nc, vertical_space, num_points_vertic) - else - vdims_names = add_space_coordinates_maybe!( - nc, - vertical_space, - num_points_vertic, - interpolated_physical_z; - names = ("z_reference",), - depending_on_dimensions = hdims_names, - ) - end - - return (hdims_names..., vdims_names...) -end - -# Ignore the interpolated_physical_z/disable_vertical_interpolation keywords in the general -# case (we only case about the specialized one for extruded spaces) -add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space, - num_points; - interpolated_physical_z = nothing, - disable_vertical_interpolation = false, -) = add_space_coordinates_maybe!(nc::NCDatasets.NCDataset, space, num_points) - -# Elevation with topography - -# `depending_on_dimensions` identifies the dimensions upon which the current one depends on -# (excluding itself). In pretty much all cases, the dimensions depend only on themselves -# (e.g., `lat` is a variable only defined on the latitudes.), and `depending_on_dimensions` -# should be an empty tuple. The only case in which this is not what happens is with `z` with -# topography. With topography, the altitude will depend on the spatial coordinates. So, -# `depending_on_dimensions` might be `("lon", "lat)`, or similar. -function add_space_coordinates_maybe!( - nc::NCDatasets.NCDataset, - space::Spaces.FiniteDifferenceSpace, - num_points, - interpolated_physical_z; - names = ("z_reference",), - depending_on_dimensions, -) - num_points_z = num_points - name, _... = names - - # Add z_reference - z_reference_dimension_dimension_exists = - dimension_exists(nc, name, (num_points_z,)) - - if !z_reference_dimension_dimension_exists - reference_altitudes = target_coordinates(space, num_points_z) - add_dimension!(nc, name, reference_altitudes; units = "m", axis = "Z") - end - - # We also have to add an extra variable with the physical altitudes - physical_name = "z_physical" - z_physical_dimension_dimension_exists = - dimension_exists(nc, physical_name, size(interpolated_physical_z)) - - if !z_physical_dimension_dimension_exists - FT = eltype(interpolated_physical_z) - dim = NCDatasets.defVar( - nc, - physical_name, - FT, - (depending_on_dimensions..., name), - ) - dim.attrib["units"] = "m" - if length(depending_on_dimensions) == 2 - dim[:, :, :] = interpolated_physical_z - elseif length(depending_on_dimensions) == 1 - dim[:, :] = interpolated_physical_z - else - error("Error in calculating z_physical") - end - end - # We do not output this name because it is not an axis - - return [name] -end - -# General hybrid space. This calls both the vertical and horizontal add_space_coordinates_maybe! -# and combines the resulting dictionaries -function target_coordinates( - space::Spaces.ExtrudedFiniteDifferenceSpace, - num_points, -) - - hcoords = vcoords = () - - num_points_horiz..., num_points_vertic = num_points - - hcoords = - target_coordinates(Spaces.horizontal_space(space), num_points_horiz) - - vertical_space = Spaces.FiniteDifferenceSpace( - Spaces.vertical_topology(space), - Spaces.staggering(space), - ) - vcoords = target_coordinates(vertical_space, num_points_vertic) - - hcoords == vcoords == () && error("Found empty space") - - return hcoords, vcoords -end - -function hcoords_from_horizontal_space( - space::Spaces.SpectralElementSpace2D, - domain::Domains.SphereDomain, - hpts, -) - # Notice LatLong not LongLat! - return [Geometry.LatLongPoint(hc2, hc1) for hc1 in hpts[1], hc2 in hpts[2]] -end - -function hcoords_from_horizontal_space( - space::Spaces.SpectralElementSpace2D, - domain::Domains.RectangleDomain, - hpts, -) - return [Geometry.XYPoint(hc1, hc2) for hc1 in hpts[1], hc2 in hpts[2]] -end - -function hcoords_from_horizontal_space( - space::Spaces.SpectralElementSpace1D, - domain::Domains.IntervalDomain, - hpts, -) - return [Geometry.XPoint(hc1) for hc1 in hpts] -end +# Defines target_coordinates, add_space_coordinates_maybe!, add_time_maybe! for a bunch of +# Spaces +include("netcdf_writer_coordinates.jl") """ - hcoords_from_horizontal_space(space, domain, hpts) + NetCDFWriter -Prepare the matrix of horizontal coordinates with the correct type according to the given `space` -and `domain` (e.g., `ClimaCore.Geometry.LatLongPoint`s). +A struct to remap `ClimaCore` `Fields` to rectangular grids and save the output to NetCDF +files. """ -function hcoords_from_horizontal_space(space, domain, hpts) end - struct NetCDFWriter{T, TS, DI} <: AbstractWriter - """The base folder where to save the files.""" output_dir::String @@ -472,6 +25,7 @@ struct NetCDFWriter{T, TS, DI} <: AbstractWriter # just a few remappers because realistically we need to support fields defined on the # entire space and fields defined on 2D slices. However, handling this distinction at # construction time is quite difficult. + """ClimaCore `Remapper`s that interpolate Fields to rectangular grids.""" remappers::Dict{String, Remapper} """ Tuple/Array of integers that identifies how many points to use for interpolation @@ -496,7 +50,7 @@ struct NetCDFWriter{T, TS, DI} <: AbstractWriter disable_vertical_interpolation::Bool """Areas of memory preallocated where the interpolation output is saved. Only the root - process uses this""" + process uses this.""" preallocated_output_arrays::DI end @@ -519,7 +73,7 @@ performing a pointwise (non-conservative) remapping first. Keyword arguments ================== -- `cspace`: Space where the `Fields` are defined. +- `space`: `Space` where the `Fields` are defined. - `output_dir`: The base folder where the files should be saved. - `num_points`: How many points to use along the different dimensions to interpolate the fields. This is a tuple of integers, typically having meaning Long-Lat-Z, @@ -532,13 +86,12 @@ Keyword arguments """ function NetCDFWriter( - cspace, + space, output_dir; num_points = (180, 90, 50), disable_vertical_interpolation = false, - compression_level = 0, + compression_level = 9, ) - space = cspace horizontal_space = Spaces.horizontal_space(space) is_horizontal_space = horizontal_space == space @@ -674,6 +227,31 @@ function interpolate_field!(writer::NetCDFWriter, field, diagnostic, u, p, t) return nothing end +""" + save_diagnostic_to_disk!( + writer::NetCDFWriter, + field, + diagnostic, + u, + p, + t, + ) + +Save the resampled `field` produced by `diagnostic` as directed by the `writer`. + +Only the root process does something here. + +The target file is determined by `output_short_name(diagnostic)`. If the target file already +exists, append to it. If not, create a new file. If the file does not contain dimensions, +they are added the first time something is written. + +Attributes are appended to the dataset: +- `short_name` +- `long_name` +- `units` +- `comments` +- `start_date` +""" function save_diagnostic_to_disk!( writer::NetCDFWriter, field, @@ -690,7 +268,8 @@ function save_diagnostic_to_disk!( space = axes(field) FT = Spaces.undertype(space) - output_path = joinpath(writer.output_dir, "$(output_short_name(diagnostic)).nc") + output_path = + joinpath(writer.output_dir, "$(output_short_name(diagnostic)).nc") if !haskey(writer.open_files, output_path) # Append or write a new file @@ -725,11 +304,12 @@ function save_diagnostic_to_disk!( ("time", dim_names...), deflatelevel = writer.compression_level, ) - v.attrib["short_name"] = var.short_name - v.attrib["long_name"] = diagnostic.output_long_name - v.attrib["units"] = var.units - v.attrib["comments"] = var.comments - v.attrib["start_date"] = string(p.start_date) + v.attrib["short_name"] = var.short_name::String + v.attrib["long_name"] = diagnostic.output_long_name::String + v.attrib["units"] = var.units::String + v.attrib["comments"] = var.comments::String + # FIXME: We are hardcoding p.start_date ! + v.attrib["start_date"] = string(p.start_date)::String temporal_size = 0 end @@ -739,6 +319,9 @@ function save_diagnostic_to_disk!( nc["time"][time_index] = t + # FIXME: We are hardcoding p.start_date ! + nc["date"][time_index] = string(p.start_date + Dates.Second(t)) + # TODO: It would be nice to find a cleaner way to do this if length(dim_names) == 3 v[time_index, :, :, :] = interpolated_field @@ -760,3 +343,11 @@ function write_field!(writer::NetCDFWriter, field, diagnostic, u, p, t) save_diagnostic_to_disk!(writer, field, diagnostic, u, p, t) return nothing end + +function Base.show(io::IO, writer::NetCDFWriter) + num_open_files = length(keys(writer.open_files)) + print( + io, + "NetCDFWriter, writing to $(writer.output_dir) ($num_open_files files open)", + ) +end diff --git a/src/netcdf_writer_coordinates.jl b/src/netcdf_writer_coordinates.jl new file mode 100644 index 00000000..d2b29ba8 --- /dev/null +++ b/src/netcdf_writer_coordinates.jl @@ -0,0 +1,451 @@ +""" + add_dimension!(nc::NCDatasets.NCDataset, + name::String, + points; + kwargs...) + +Add dimension identified by `name` in the given `nc` file and fill it with the given +`points`. +""" +function add_dimension!( + nc::NCDatasets.NCDataset, + name::String, + points; + kwargs..., +) + FT = eltype(points) + + NCDatasets.defDim(nc, name, size(points)[end]) + + dim = NCDatasets.defVar(nc, name, FT, (name,)) + for (k, v) in kwargs + dim.attrib[String(k)] = v + end + + dim[:] = points + + return nothing +end + +""" + dimension_exists( + nc::NCDatasets.NCDataset, + name::String, + expected_size::Tuple, + ) + +Return whether the given dimension exists in the given dataset, and if yes, it has the same +size as `expected_size`. +""" +function dimension_exists( + nc::NCDatasets.NCDataset, + name::String, + expected_size::Tuple, +) + if haskey(nc, name) + if size(nc[name]) != expected_size + error("Incompatible $name dimension already exists in file") + else + return true + end + else + return false + end +end + +""" + add_time_maybe!(nc::NCDatasets.NCDataset, + float_type::Type{FT}; + kwargs...) where {FT} + +Add the `time` dimension (with infinite size) to the given NetCDF file if not already there. +Optionally, add all the keyword arguments as attributes. + +Also add a `date` dataset (as a string). +""" +function add_time_maybe!( + nc::NCDatasets.NCDataset, + float_type::Type{FT}; + kwargs..., +) where {FT} + + # If we already have time, do nothing + haskey(nc, "time") && return nothing + + NCDatasets.defDim(nc, "time", Inf) + dim = NCDatasets.defVar(nc, "time", FT, ("time",)) + NCDatasets.defVar(nc, "date", String, ("time",)) + for (k, v) in kwargs + dim.attrib[String(k)] = v + end + return nothing +end + +""" + add_space_coordinates_maybe!(nc::NCDatasets.NCDataset, + space::Spaces.AbstractSpace, + num_points; + names) + +Add dimensions relevant to the `space` to the given `nc` NetCDF file. The range is +automatically determined and the number of points is set with `num_points`, which has to be +an iterable of size N, where N is the number of dimensions of the space. For instance, 3 for +a cubed sphere, 2 for a surface, 1 for a column. + +The function returns an array with the names of the relevant dimensions. (We want arrays +because we want to preserve the order to match the one in num_points). + +In some cases, the names are adjustable passing the keyword `names`. +""" +function add_space_coordinates_maybe! end + +""" + target_coordinates!(space::Spaces.AbstractSpace, + num_points) + +Return the range of interpolation coordinates. The range is automatically determined and the +number of points is set with `num_points`, which has to be an iterable of size N, where N is +the number of dimensions of the space. For instance, 3 for a cubed sphere, 2 for a surface, +1 for a column. +""" +function target_coordinates(space, num_points) end + +function target_coordinates( + space::S, + num_points, +) where { + S <: + Union{Spaces.CenterFiniteDifferenceSpace, Spaces.FaceFiniteDifferenceSpace}, +} + # Exponentially spaced with base e + # + # We mimic something that looks like pressure levels + # + # p ~ pā‚€ exp(-z/H) + # + # We assume H to be 7000, which is a good scale height for the Earth atmosphere + H_EARTH = 7000 + + num_points_z = num_points[] + FT = Spaces.undertype(space) + topology = Spaces.topology(space) + vert_domain = topology.mesh.domain + z_min, z_max = FT(vert_domain.coord_min.z), FT(vert_domain.coord_max.z) + # We floor z_min to avoid having to deal with the singular value z = 0. + z_min = max(z_min, 100) + exp_z_min = exp(-z_min / H_EARTH) + exp_z_max = exp(-z_max / H_EARTH) + return collect(-H_EARTH * log.(range(exp_z_min, exp_z_max, num_points_z))) +end + +# Column +function add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space::Spaces.FiniteDifferenceSpace, + num_points_z; + names = ("z",), +) + name, _... = names + z_dimension_exists = dimension_exists(nc, name, (num_points_z,)) + if !z_dimension_exists + zpts = target_coordinates(space, num_points_z) + add_dimension!(nc, name, zpts, units = "m", axis = "Z") + end + return [name] +end + +add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space::Spaces.AbstractSpectralElementSpace, + num_points; +) = add_space_coordinates_maybe!( + nc, + space, + num_points, + Meshes.domain(Spaces.topology(space)); +) + + +# For the horizontal space, we also have to look at the domain, so we define another set of +# functions that dispatches over the domain +target_coordinates(space::Spaces.AbstractSpectralElementSpace, num_points) = + target_coordinates(space, num_points, Meshes.domain(Spaces.topology(space))) + +# Box +function target_coordinates( + space::Spaces.SpectralElementSpace2D, + num_points, + domain::Domains.RectangleDomain, +) + num_points_x, num_points_y = num_points + FT = Spaces.undertype(space) + xmin = FT(domain.interval1.coord_min.x) + xmax = FT(domain.interval1.coord_max.x) + ymin = FT(domain.interval2.coord_min.y) + ymax = FT(domain.interval2.coord_max.y) + xpts = collect(range(xmin, xmax, num_points_x)) + ypts = collect(range(ymin, ymax, num_points_y)) + return (xpts, ypts) +end + +# Plane +function target_coordinates( + space::Spaces.SpectralElementSpace1D, + num_points, + domain::Domains.IntervalDomain, +) + num_points_x, _... = num_points + FT = Spaces.undertype(space) + xmin = FT(domain.coord_min.x) + xmax = FT(domain.coord_max.x) + xpts = collect(range(xmin, xmax, num_points_x)) + return (xpts) +end + +# Cubed sphere +function target_coordinates( + space::Spaces.SpectralElementSpace2D, + num_points, + ::Domains.SphereDomain, +) + num_points_long, num_points_lat = num_points + FT = Spaces.undertype(space) + longpts = collect(range(FT(-180), FT(180), num_points_long)) + latpts = collect(range(FT(-90), FT(90), num_points_lat)) + + return (longpts, latpts) +end + +# Box +function add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space::Spaces.SpectralElementSpace2D, + num_points, + ::Domains.RectangleDomain; + names = ("x", "y"), +) + xname, yname = names + num_points_x, num_points_y = num_points + x_dimension_exists = dimension_exists(nc, xname, (num_points_x,)) + y_dimension_exists = dimension_exists(nc, yname, (num_points_y,)) + + if !x_dimension_exists && !y_dimension_exists + xpts, ypts = target_coordinates(space, num_points) + add_dimension!(nc, xname, xpts; units = "m", axis = "X") + add_dimension!(nc, yname, ypts; units = "m", axis = "Y") + end + return [xname, yname] +end + +# Plane +function add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space::Spaces.SpectralElementSpace1D, + num_points, + ::Domains.IntervalDomain; + names = ("x",), +) + xname, _... = names + num_points_x, = num_points + x_dimension_exists = dimension_exists(nc, xname, (num_points_x,)) + + if !x_dimension_exists + xpts = target_coordinates(space, num_points) + add_dimension!(nc, xname, xpts; units = "m", axis = "X") + end + return [xname] +end + +# Cubed sphere +function add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space::Spaces.SpectralElementSpace2D, + num_points, + ::Domains.SphereDomain; + names = ("lon", "lat"), +) + longname, latname = names + num_points_long, num_points_lat = num_points + + long_dimension_exists = dimension_exists(nc, longname, (num_points_long,)) + lat_dimension_exists = dimension_exists(nc, latname, (num_points_lat,)) + + if !long_dimension_exists && !lat_dimension_exists + longpts, latpts = target_coordinates(space, num_points) + add_dimension!( + nc, + longname, + longpts; + units = "degrees_east", + axis = "X", + ) + add_dimension!(nc, latname, latpts; units = "degrees_north", axis = "Y") + end + + return [longname, latname] +end + +# General hybrid space. This calls both the vertical and horizontal add_space_coordinates_maybe! +# and combines the resulting dictionaries +function add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space::Spaces.ExtrudedFiniteDifferenceSpace, + num_points; + interpolated_physical_z = nothing, +) + + hdims_names = vdims_names = [] + + num_points_horiz..., num_points_vertic = num_points + + # Being an Extruded space, we can assume that we have an horizontal and a vertical space. + # We can also assume that the vertical space has dimension 1 + horizontal_space = Spaces.horizontal_space(space) + + hdims_names = + add_space_coordinates_maybe!(nc, horizontal_space, num_points_horiz) + + vertical_space = Spaces.FiniteDifferenceSpace( + Spaces.vertical_topology(space), + Spaces.staggering(space), + ) + + if Spaces.grid(space).hypsography isa Grids.Flat + vdims_names = + add_space_coordinates_maybe!(nc, vertical_space, num_points_vertic) + else + vdims_names = add_space_coordinates_maybe!( + nc, + vertical_space, + num_points_vertic, + interpolated_physical_z; + names = ("z_reference",), + depending_on_dimensions = hdims_names, + ) + end + + return (hdims_names..., vdims_names...) +end + +# Ignore the interpolated_physical_z/disable_vertical_interpolation keywords in the general +# case (we only case about the specialized one for extruded spaces) +add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space, + num_points; + interpolated_physical_z = nothing, + disable_vertical_interpolation = false, +) = add_space_coordinates_maybe!(nc::NCDatasets.NCDataset, space, num_points) + +# Elevation with topography + +# `depending_on_dimensions` identifies the dimensions upon which the current one depends on +# (excluding itself). In pretty much all cases, the dimensions depend only on themselves +# (e.g., `lat` is a variable only defined on the latitudes.), and `depending_on_dimensions` +# should be an empty tuple. The only case in which this is not what happens is with `z` with +# topography. With topography, the altitude will depend on the spatial coordinates. So, +# `depending_on_dimensions` might be `("lon", "lat)`, or similar. +function add_space_coordinates_maybe!( + nc::NCDatasets.NCDataset, + space::Spaces.FiniteDifferenceSpace, + num_points, + interpolated_physical_z; + names = ("z_reference",), + depending_on_dimensions, +) + num_points_z = num_points + name, _... = names + + # Add z_reference + z_reference_dimension_dimension_exists = + dimension_exists(nc, name, (num_points_z,)) + + if !z_reference_dimension_dimension_exists + reference_altitudes = target_coordinates(space, num_points_z) + add_dimension!(nc, name, reference_altitudes; units = "m", axis = "Z") + end + + # We also have to add an extra variable with the physical altitudes + physical_name = "z_physical" + z_physical_dimension_dimension_exists = + dimension_exists(nc, physical_name, size(interpolated_physical_z)) + + if !z_physical_dimension_dimension_exists + FT = eltype(interpolated_physical_z) + dim = NCDatasets.defVar( + nc, + physical_name, + FT, + (depending_on_dimensions..., name), + ) + dim.attrib["units"] = "m" + if length(depending_on_dimensions) == 2 + dim[:, :, :] = interpolated_physical_z + elseif length(depending_on_dimensions) == 1 + dim[:, :] = interpolated_physical_z + else + error("Error in calculating z_physical") + end + end + # We do not output this name because it is not an axis + + return [name] +end + +# General hybrid space. This calls both the vertical and horizontal add_space_coordinates_maybe! +# and combines the resulting dictionaries +function target_coordinates( + space::Spaces.ExtrudedFiniteDifferenceSpace, + num_points, +) + + hcoords = vcoords = () + + num_points_horiz..., num_points_vertic = num_points + + hcoords = + target_coordinates(Spaces.horizontal_space(space), num_points_horiz) + + vertical_space = Spaces.FiniteDifferenceSpace( + Spaces.vertical_topology(space), + Spaces.staggering(space), + ) + vcoords = target_coordinates(vertical_space, num_points_vertic) + + hcoords == vcoords == () && error("Found empty space") + + return hcoords, vcoords +end + +function hcoords_from_horizontal_space( + space::Spaces.SpectralElementSpace2D, + domain::Domains.SphereDomain, + hpts, +) + # Notice LatLong not LongLat! + return [Geometry.LatLongPoint(hc2, hc1) for hc1 in hpts[1], hc2 in hpts[2]] +end + +function hcoords_from_horizontal_space( + space::Spaces.SpectralElementSpace2D, + domain::Domains.RectangleDomain, + hpts, +) + return [Geometry.XYPoint(hc1, hc2) for hc1 in hpts[1], hc2 in hpts[2]] +end + +function hcoords_from_horizontal_space( + space::Spaces.SpectralElementSpace1D, + domain::Domains.IntervalDomain, + hpts, +) + return [Geometry.XPoint(hc1) for hc1 in hpts] +end + +""" + hcoords_from_horizontal_space(space, domain, hpts) + +Prepare the matrix of horizontal coordinates with the correct type according to the given `space` +and `domain` (e.g., `ClimaCore.Geometry.LatLongPoint`s). +""" +function hcoords_from_horizontal_space(space, domain, hpts) end diff --git a/src/utils.jl b/src/utils.jl index 3af124ef..183f6b59 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -6,7 +6,7 @@ Convert a time in seconds to a string representing the time in "short" units. Examples: ======== -```jldoctest +```julia julia> seconds_to_str_short(0) "0s" @@ -50,7 +50,7 @@ Convert a time in seconds to a string representing the time in "longer" units. Examples: ======== -```jldoctest +```julia julia> seconds_to_str_long(0) "0 Seconds" diff --git a/test/TestTools.jl b/test/TestTools.jl index 7af15c17..874cc834 100644 --- a/test/TestTools.jl +++ b/test/TestTools.jl @@ -1,3 +1,5 @@ +import Dates + import SciMLBase import ClimaCore @@ -20,17 +22,45 @@ function ColumnCenterFiniteDifferenceSpace( return ClimaCore.Spaces.CenterFiniteDifferenceSpace(topology) end +function SphericalShellSpace(; + radius = 6371.0, + height = 10.0, + nelements = 10, + zelem = 10, + npolynomial = 4, + context = ClimaComms.SingletonCommsContext(), + FT = Float64, +) + vertdomain = ClimaCore.Domains.IntervalDomain( + ClimaCore.Geometry.ZPoint(FT(0)), + ClimaCore.Geometry.ZPoint(FT(height)); + boundary_names = (:bottom, :top), + ) + vertmesh = ClimaCore.Meshes.IntervalMesh(vertdomain; nelems = zelem) + vert_center_space = ClimaCore.Spaces.CenterFiniteDifferenceSpace(vertmesh) + + horzdomain = ClimaCore.Domains.SphereDomain(radius) + horzmesh = ClimaCore.Meshes.EquiangularCubedSphere(horzdomain, nelements) + horztopology = ClimaCore.Topologies.Topology2D(context, horzmesh) + quad = ClimaCore.Spaces.Quadratures.GLL{npolynomial + 1}() + horzspace = ClimaCore.Spaces.SpectralElementSpace2D(horztopology, quad) + + return ClimaCore.Spaces.ExtrudedFiniteDifferenceSpace( + horzspace, + vert_center_space, + ) +end + """ - create_problem_algo() + create_problem() An ODE problem for an exponential decay. """ -function create_problem(; t0 = 0.0, tf = 1.0, dt = 1e-3) +function create_problem(space; t0 = 0.0, tf = 1.0, dt = 1e-3) # Let's solve an exponential decay - space = ColumnCenterFiniteDifferenceSpace() Y = ClimaCore.Fields.FieldVector(; my_var = ones(space)) - p = (; tau = -0.1) + p = (; tau = -0.1, start_date = Dates.DateTime(476, 9, 4)) function exp_tendency!(dY, Y, p, t) @. dY.my_var = p.tau * Y.my_var diff --git a/test/callback.jl b/test/callback.jl index 2487c79b..5d8bcbb1 100644 --- a/test/callback.jl +++ b/test/callback.jl @@ -21,7 +21,8 @@ include("TestTools.jl") tf = 1.0 dt = 1e-3 - args, kwargs = create_problem(; t0, tf, dt) + space = ColumnCenterFiniteDifferenceSpace() + args, kwargs = create_problem(space; t0, tf, dt) expected_called = convert(Int, (tf - t0) / dt) @@ -49,7 +50,8 @@ end tf = 1.0 dt = 1e-3 - args, kwargs = create_problem(; t0, tf, dt) + space = ColumnCenterFiniteDifferenceSpace() + args, kwargs = create_problem(space; t0, tf, dt) expected_called = convert(Int, (tf - t0) / (divisor * dt)) @@ -81,7 +83,7 @@ end callback_dt = Callback(callback_func, scheduled_func) callback_dt2 = Callback(callback_func2, scheduled_func2) - args, kwargs = create_problem(; t0, tf, dt) + args, kwargs = create_problem(space; t0, tf, dt) expected_called = convert(Int, (tf - t0) / dt_callback) expected_called2 = convert(Int, floor((tf - t0 - t_start2) / dt_callback2)) diff --git a/test/diagnostics.jl b/test/diagnostics.jl index 569141b9..9d18fcbe 100644 --- a/test/diagnostics.jl +++ b/test/diagnostics.jl @@ -38,21 +38,6 @@ include("TestTools.jl") accumulated_value_field = ones(space) accumulated_value_array = fill!(similar(array), 1) - @test isnothing( - ClimaDiagnostics.accumulate!( - accumulated_value_field, - field, - nothing, - ), - ) - @test isnothing( - ClimaDiagnostics.accumulate!( - accumulated_value_array, - array, - nothing, - ), - ) - ClimaDiagnostics.accumulate!(accumulated_value_field, field, +) @test extrema(accumulated_value_field) == (FT(2), FT(2)) diff --git a/test/integration_test.jl b/test/integration_test.jl new file mode 100644 index 00000000..67e00878 --- /dev/null +++ b/test/integration_test.jl @@ -0,0 +1,121 @@ +using Test +using Profile +using ProfileCanvas + +import SciMLBase +import NCDatasets + +import ClimaDiagnostics + +include("TestTools.jl") + +function setup_integrator(output_dir) + t0 = 0.0 + tf = 10.0 + dt = 1.0 + space = SphericalShellSpace() + args, kwargs = create_problem(space; t0, tf, dt) + + @info "Writing output to $output_dir" + + h5_writer = ClimaDiagnostics.Writers.HDF5Writer(output_dir) + nc_writer = ClimaDiagnostics.Writers.NetCDFWriter( + space, + output_dir; + num_points = (10, 5, 3), + ) + + function compute_my_var!(out, u, p, t) + if isnothing(out) + return copy(u.my_var) + else + out .= u.my_var + return nothing + end + end + + simple_var = ClimaDiagnostics.DiagnosticVariable(; + compute! = compute_my_var!, + short_name = "YO", + long_name = "YO YO", + ) + + average_diagnostic = ClimaDiagnostics.ScheduledDiagnostic( + variable = simple_var, + output_writer = nc_writer, + reduction_time_func = (+), + output_schedule_func = ClimaDiagnostics.Callbacks.DivisorSchedule(2), + pre_output_hook! = ClimaDiagnostics.average_pre_output_hook!, + ) + inst_diagnostic = ClimaDiagnostics.ScheduledDiagnostic( + variable = simple_var, + output_writer = nc_writer, + ) + inst_every3s_diagnostic = ClimaDiagnostics.ScheduledDiagnostic( + variable = simple_var, + output_writer = nc_writer, + output_schedule_func = ClimaDiagnostics.Callbacks.EveryDtSchedule( + 3.0, + t_start = t0, + ), + ) + inst_diagnostic_h5 = ClimaDiagnostics.ScheduledDiagnostic( + variable = simple_var, + output_writer = h5_writer, + ) + scheduled_diagnostics = [ + average_diagnostic, + inst_diagnostic, + inst_diagnostic_h5, + inst_every3s_diagnostic, + ] + + return ClimaDiagnostics.IntegratorWithDiagnostics( + SciMLBase.init(args...; kwargs...), + scheduled_diagnostics, + ) +end + +@testset "A full problem" begin + mktempdir() do output_dir + integrator = setup_integrator(output_dir) + + SciMLBase.solve!(integrator) + + NCDatasets.NCDataset(joinpath(output_dir, "YO_1it_inst.nc")) do nc + @test nc["YO"].attrib["short_name"] == "YO" + @test nc["YO"].attrib["long_name"] == "YO YO, Instantaneous" + @test size(nc["YO"]) == (11, 10, 5, 3) + end + + NCDatasets.NCDataset(joinpath(output_dir, "YO_2it_average.nc")) do nc + @test nc["YO"].attrib["short_name"] == "YO" + @test nc["YO"].attrib["long_name"] == + "YO YO, average within every 2 iterations" + @test size(nc["YO"]) == (5, 10, 5, 3) + end + + NCDatasets.NCDataset(joinpath(output_dir, "YO_3s_inst.nc")) do nc + @test nc["YO"].attrib["short_name"] == "YO" + @test nc["YO"].attrib["long_name"] == "YO YO, Instantaneous" + @test size(nc["YO"]) == (4, 10, 5, 3) + end + end +end + +@testset "Performance" begin + mktempdir() do output_dir + # Flame + integrator = setup_integrator(output_dir) + prof = Profile.@profile SciMLBase.solve!(integrator) + results = Profile.fetch() + ProfileCanvas.html_file("flame.html", results) + + # Allocations + integrator = setup_integrator(output_dir) + prof = Profile.Allocs.@profile SciMLBase.solve!(integrator) + results = Profile.Allocs.fetch() + allocs = ProfileCanvas.view_allocs(results) + ProfileCanvas.html_file("allocs.html", allocs) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 7678e797..fce2d60e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -11,6 +11,8 @@ using Test @safetestset "Callbacks and Schdules" begin @time include("callback.jl") end @safetestset "DiagnosticVariable" begin @time include("diagnostic_variable.jl") end @safetestset "SchduledDiagnostics" begin @time include("diagnostics.jl") end + +@safetestset "Integration test" begin @time include("integration_test.jl") end #! format: on return nothing diff --git a/test/writers.jl b/test/writers.jl index d1499a05..ef62a560 100644 --- a/test/writers.jl +++ b/test/writers.jl @@ -2,6 +2,8 @@ using Test import ClimaDiagnostics.Writers +include("TestTools.jl") + @testset "DictWriter" begin writer = Writers.DictWriter() @@ -13,3 +15,13 @@ import ClimaDiagnostics.Writers Writers.write_field!(writer, 50.0, "mytest2", nothing, nothing, 8.0) @test writer.dict["mytest2"][8.0] == 50.0 end + +@testset "NetCDFWriter" begin + space = SphericalShellSpace() + + mktempdir() do output_dir + writer = Writers.NetCDFWriter(space, output_dir) + # Check Base.show + @test occursin("0 files open", "$writer") + end +end From c3d893f4a5ef9bc7d21aed068e51e878dda346a5 Mon Sep 17 00:00:00 2001 From: Gabriele Bozzola Date: Tue, 9 Apr 2024 16:37:31 -0700 Subject: [PATCH 2/2] Bump to 0.04 --- Project.toml | 2 +- docs/Project.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 4399b340..c7fde17d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "ClimaDiagnostics" uuid = "1ecacbb8-0713-4841-9a07-eb5aa8a2d53f" authors = ["Gabriele Bozzola "] -version = "0.0.3" +version = "0.0.4" [deps] Accessors = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697" diff --git a/docs/Project.toml b/docs/Project.toml index f26d43dc..b544b444 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -3,5 +3,5 @@ ClimaDiagnostics = "1ecacbb8-0713-4841-9a07-eb5aa8a2d53f" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" [compat] -ClimaDiagnostics = "0.0.3" +ClimaDiagnostics = "0.0.4" Documenter = "0.27"