Add plotting enhancements - #454
Conversation
Set conditional based on values for different float formats going into original DataFrame Set number of significant values in adf_web.py to catch unnecessary trailing zeros in decimals. Also move most imports to beginning of script
This reverts commit 0dd3910.
nusbaume
left a comment
There was a problem hiding this comment.
Thanks for all of the code cleanup and plot enhancements @justin-richling! I realize it looks like I have a bazillion comments, but the vast majority should hopefully be easy or trivial to implement. Of course if you do have any questions or concerns with anything I wrote just let me know!
| @@ -0,0 +1,649 @@ | |||
| name: adf_npl-2024a | |||
There was a problem hiding this comment.
I personally don't think we should have an NPL environment file in the ADF (or LDF). In general it's best to only have the minimum number of packages you need to run all the ADF scripts, which I am guessing is significantly less than what is contained here.
If you need help creating a more up-to-date environment file for the ADF then please let me know, as I would be happy to help work on it. Thanks!
| !.vscode/extensions.json | ||
| .history | ||
|
|
||
| #NCL RGB files |
There was a problem hiding this comment.
Total nit-pick, but I might add a space in the comment here to match the format of all the other comments in this file:
| #NCL RGB files | |
| # NCL RGB files |
| vres["season"] = plot['season'] | ||
| vres["hemi"] = plot['hemi'] |
There was a problem hiding this comment.
Another nit-pick, but I might use the same quotation marks on both sides of the equation here (in case someone who doesn't know python well looks at the code and assumes the behavior is different):
| vres["season"] = plot['season'] | |
| vres["hemi"] = plot['hemi'] | |
| vres['season'] = plot['season'] | |
| vres['hemi'] = plot['hemi'] |
| vres["umdlfld_nowrap"] = umseasons[s] | ||
| vres["vmdlfld_nowrap"] = vmseasons[s] | ||
| vres["uobsfld_nowrap"] = uoseasons[s] | ||
| vres["vobsfld_nowrap"] = voseasons[s] | ||
| vres["udiffld_nowrap"] = udseasons[s] | ||
| vres["vdiffld_nowrap"] = vdseasons[s] | ||
| vres["upctdiffld_nowrap"] = upseasons[s] | ||
| vres["vpctdiffld_nowrap"] = vpseasons[s] |
There was a problem hiding this comment.
I realize this is purely an opinion, but in general I feel that "kwarg"-like dictionary inputs into python functions should only contain metadata (e.g. plot_type), and not required data like the fields being plotted. Given this, I think I would bring in the extra vector data fields as optional function arguments instead of keyword arguments. Of course feel free to push back here if you disagree!
| pf.plot_map_and_save(adfobj, plot_name, case_nickname, base_nickname, | ||
| [syear_cases[case_idx],eyear_cases[case_idx]], | ||
| [syear_baseline,eyear_baseline], | ||
| umseasons[s], uoseasons[s], | ||
| udseasons[s], upseasons[s], | ||
| obs=obs, **vres) |
There was a problem hiding this comment.
If you do accept my suggestion on passing the main data fields as optional arguments, then this function call would look something like this instead:
| pf.plot_map_and_save(adfobj, plot_name, case_nickname, base_nickname, | |
| [syear_cases[case_idx],eyear_cases[case_idx]], | |
| [syear_baseline,eyear_baseline], | |
| umseasons[s], uoseasons[s], | |
| udseasons[s], upseasons[s], | |
| obs=obs, **vres) | |
| pf.plot_map_and_save(adfobj, plot_name, case_nickname, base_nickname, | |
| [syear_cases[case_idx],eyear_cases[case_idx]], | |
| [syear_baseline,eyear_baseline], | |
| umseasons[s], uoseasons[s], | |
| udseasons[s], upseasons[s], | |
| vm=vmseasons[s], vo=voseasons[s], | |
| vd=vdseasons[s], vp=vpseasons[s], | |
| obs=obs, **vres) |
Then in the plot_map_and_save function itself all of the vx variables would be None in the function argument list.
|
|
||
| fig, ax = plt.subplots(nrows=3) | ||
| ax = [ax[0],ax[1],ax[2]] | ||
| fig, ax = plt.subplots(nrows=3)#figsize=(6,8), |
There was a problem hiding this comment.
Remove commented-out code?
| plot_kwargs = kwargs.copy() | ||
| plot_kwargs["colormap_2d"] = use_cmap | ||
| diff_kwargs = plot_kwargs.copy() | ||
| diff_kwargs["type"] = "diff" | ||
| diff_kwargs.pop("norm", None) |
There was a problem hiding this comment.
Instead of making multiple copies, why not just make one copy that is then modified for the diff and pctdiff plots? The only reason you would need multiple copies if you needed to save diff_kwargs after creating the zonal plot for the difference field.
| levs = np.unique(np.array(cp_info['levels1'])) | ||
| levs_diff = np.unique(np.array(cp_info['levelsdiff'])) | ||
| levs_pctdiff = np.unique(np.array(cp_info['levelspctdiff'])) | ||
| # Generate zonal plot: |
There was a problem hiding this comment.
Meridional plot?
| # Generate zonal plot: | |
| # Generate meridional plot: |
| fig, ax = plt.subplots(figsize=(8,10),nrows=4, constrained_layout=True, | ||
| sharey=True, **cp_info['subplots_opt']) | ||
|
|
||
| levs = np.unique(np.array(levels_sim)) |
There was a problem hiding this comment.
Just noting again that naming this variable to something like contour_levs might help make it obvious that this is not related to vertical levels.
| use_cmap = kwargs.get("colormap_2d", False) | ||
| plot_kwargs = kwargs.copy() | ||
| plot_kwargs["colormap_2d"] = use_cmap | ||
| diff_kwargs = plot_kwargs.copy() | ||
| diff_kwargs["type"] = "diff" | ||
| diff_kwargs.pop("norm", None) |
There was a problem hiding this comment.
Can we get away with only one copy of kwargs here as well?
| height="90%", # height : 90% | ||
| loc='lower left', | ||
| bbox_to_anchor=(1.05, 0.05, 1, 1), | ||
| bbox_transform=ax2.transAxes, |
There was a problem hiding this comment.
I think this ax2 needs to be pointed at an axs element
| height="90%", # height : 90% | ||
| loc='lower left', | ||
| bbox_to_anchor=(1.05, 0.05, 1, 1), | ||
| bbox_transform=ax3.transAxes, |
| height="90%", # height : 90% | ||
| loc='lower left', | ||
| bbox_to_anchor=(1.05, 0.05, 1, 1), | ||
| bbox_transform=ax4.transAxes, |
| a.set_boundary(circle, transform=a.transAxes) | ||
| a.gridlines(draw_labels=False, crs=ccrs.PlateCarree(), | ||
| lw=1, color="gray",y_inline=True, | ||
| xlocs=range(-180,180,90), ylocs=range(0,90,10)) |
There was a problem hiding this comment.
Is range(0,90,10)going to work for southern hemisphere?
| loc='lower left', | ||
| bbox_to_anchor=(1.05, 0, 1, 1), | ||
| bbox_to_anchor=(1.02, 0, 1, 1), | ||
| bbox_transform=ax2.transAxes, |
| height="100%", | ||
| loc='lower left', | ||
| bbox_to_anchor=(1.02, 0, 1, 1), | ||
| bbox_transform=ax3.transAxes, |
| msg += f" Trying if this an NCL color map" | ||
|
|
||
| url = guess_ncl_url(cmap_pctdiff) | ||
| locfil = "." / f"{cmap_pctdiff}.rgb" |
There was a problem hiding this comment.
I think you have to use some pathlib thing here, probably: locfil = Path.cwd() / f"{cmap_pctdiff}.rgb"
| pctdiff_mag = diff_mag / np.abs(obs_mag) * 100.0 | ||
| #pctdiff_mag = diff_mag / np.abs((mdl_mag + obs_mag)/2) * 100.0 | ||
| pctdiff_mag = pctdiff_mag.where(np.isfinite(pctdiff_mag), np.nan) | ||
| pctdiff_mag = pctdiff_mag.fillna(0.0) |
There was a problem hiding this comment.
Is this what we actually want? Divide by zero is replaced by np.nan, but then get set to 0.0. Won't that mean that any nan value ends up looking like a valid result in the plot?
There was a problem hiding this comment.
Yeah, this is very confusing as now we are misrepresenting the true values, which is not what we want.
Would something like this be more accurate:
pctdiff_mag = xr.where(obs_mag != 0,
(mdl_mag - obs_mag) / np.abs(obs_mag) * 100,
np.nan,
)That way we also catch anywhere obs/baseline is zero, or is this already handled with the np.isfinite?
I'm hoping catching obs_mag when zero will fix the times where the pct values are excessively large that I see occassionally.
There was a problem hiding this comment.
I think isfinite does it. I think it works as:
pctdiff_mag = pctdiff_mag.where(np.isfinite(pctdiff_mag))
just get rid of the fillna line.
| ax[i].quiver(lons[skip], lats[skip], umdlfld[skip], vmdlfld[skip], mdl_mag.values[skip], | ||
| transform=ccrs.PlateCarree(), cmap='Reds') | ||
| ax[i].quiver(lons[skip], lats[skip], uobsfld[skip], vobsfld[skip], obs_mag.values[skip], | ||
| transform=ccrs.PlateCarree(), cmap='Reds') |
There was a problem hiding this comment.
Both quiver calls are unconditional inside the if i in [0,1] block — so for both i=0 (model panel) and i=1 (baseline panel), it draws model vectors first and then baseline vectors on top. Both panels end up with overlaid arrows.
It seems like the intent is i=0 → model only, i=1 → baseline only. The fix should be:
if i == 0:
ax[i].quiver(..., umdlfld[skip], vmdlfld[skip], mdl_mag.values[skip], ...)
elif i == 1:
ax[i].quiver(..., uobsfld[skip], vobsfld[skip], obs_mag.values[skip], ...)
| scale_factor: 1 | ||
| add_offset: 0 | ||
| new_unit: "" | ||
| pct_diff_contour_levels: [-100,-75,-50,-40,-30,-20,-10,-8,-6,-4,-2,0,2,4,6,8,10,20,30,40,50,75,100] |
There was a problem hiding this comment.
Before this PR, pct_diff_colormap and pct_diff_contour_levels were at the top level of a variable's YAML entry and were found directly in kwargs.
Now:
- If the user has a
global_latlon_map(or similar) sub-block, they must putpct_diff_colormapinside that block
which means that the previous behavior of top-level placement is silently ignored. - If
plot_typeis not passed into the function at all,plot_type_dict = {}and there is no way for the user to override hard-coded values that are given inlib/plotting_utils.py: - Line 863:
cmap_pctdiff = "PuOr_r" - Line 882:
cmap_pctdiff = 'PuOr_r' - Line 896:
levels_pctdiff = [-100,-75,-50,-40,-30,-20,-10,-8,-6,-4,-2,0,2,4,6,8,10,20,30,40,50,75,100]
So the PR description's claim that users can still supply these args is only partially true. The configurability is preserved when nested under a plot-type block but silently lost in the flat/top-level case that was standard before.
This probably doesn't matter much, but it complicates customizing plots from the YAML file. Needs documentation at minimum.
There was a problem hiding this comment.
This is a great observation @brianpm, thanks for pointing it out!
| vmax = kwargs.get("vmax", None) | ||
| if use_cmap: | ||
| if kwargs["type"] == "pctdiff": | ||
| cmap = kwargs.get("pct_diff_colormap", "PuOr") |
There was a problem hiding this comment.
The hard-coded yaml default was PuOr_r right? Should that be used here?
There was a problem hiding this comment.
Ah, good catch, thanks.
brianpm
left a comment
There was a problem hiding this comment.
I left a few comments where I saw some inconsistent changes. Looks good overall. My first thought is that we could probably reduce total code, but no need for now.
nusbaume
left a comment
There was a problem hiding this comment.
Review generated by Claude, following the repository's AGENTS.md (on main).
Summary
This PR reworks contour/colormap resolution so levels and colormaps can vary by plot type, hemisphere and pressure level, merges the vector map into plot_map_and_save, adds NCL colormap support, and tidies units handling. The direction is good and the unit fixes (adf_dataset.py raw_units, keep_attrs=True in zonal_mean_xr) are clean. However, there are a few correctness problems that will show up in a default ADF run, so I don't think it's ready to merge as-is.
Per AGENTS.md §3, note that CI covers none of this: no file in this PR is in the pylint testable_files set, and the pytest suite only touches adf_base/adf_config. A green checkmark here means very little — everything below was found by reading and by executing the new helpers against the shipped adf_variable_defaults.yaml in the pinned adf_v1.0.0 environment.
Also flagging up front: the PR currently reports mergeable: false against main.
Blocking
1. Vector-component variables get the vector's name as the plot title on every scalar plot — lib/plotting_utils.py:958-973 (add_var_to_vres)
add_var_to_vres checks vector_name first and unconditionally overrides var_name with it, even when the caller is making an ordinary scalar plot. Running it against the shipped defaults:
U -> figure title uses var_name = 'Wind'
V -> figure title uses var_name = 'Wind'
TAUX -> figure title uses var_name = 'Surface Wind Stress'
TAUY -> figure title uses var_name = 'Surface Wind Stress'
PRECT -> figure title uses var_name = 'PRECT'
var_name feeds fig.suptitle(f"{var_name}: {season}") (plotting_functions.py:256-260, 606-610, 952, 979, 1295, 1322). U and V are in the default diag_var_list, and global_latlon_map, zonal_mean, meridional_mean and polar_map all call add_var_to_vres — so in a stock run every scalar U and V plot is titled "Wind". The vector-name branch should only be taken when the caller is actually plotting the vector (e.g. gated on the vector flag that global_latlon_vect_map.py already sets).
2. Explicit 3-entry contour_levels lists are silently discarded — lib/plotting_utils.py:632-648 (resolve_levels / process_entry)
For kind == "levels", a list of exactly three entries falls into the else branch, logs "please add more values", and returns None — so the plot silently falls back to data-range levels. On main, contour_levels was used verbatim regardless of length (plotting_utils.py:357-361 at the merge base). Any user whose variable-defaults file has e.g. contour_levels: [-1, 0, 1] gets different plots with no error and nothing on stdout — the AGENTS.md §4.3 backwards-compatibility rule. No variable in the two shipped defaults files has a 3-entry explicit list, so this only bites user-supplied files, but that's exactly where it's hardest to notice.
3. NCL colormap parser silently corrupts values and crashes on plausible inputs — lib/plotting_utils.py:399-452 (read_ncl_colormap)
The trailing-comment strip at line 441-443 uses line_str[:match.start()-1]. The -1 drops a real character whenever there's no space before the comment marker. Executed against the PR code:
"255 0 0 ; red" -> [255.0, 0.0, 0.0] correct
"255 0 10# red" -> [255.0, 0.0, 1.0] blue channel 10 silently becomes 1
Same function, other inputs:
- comment-only / header-only file →
UnboundLocalError: cannot access local variable 'table' - single data row →
tableis 1-D, thenncl_to_mplraisesIndexError: tuple index out of range except:at line 444 is bare (AGENTS.md§6.3) and leavesrowbound to the previous row when a line fails to parse
The silent-truncation case is the one that matters most (AGENTS.md §5.2): wrong colors, no warning.
4. Runtime colormap download — lib/plotting_utils.py:391-412
read_ncl_colormap does filename = Path.cwd() / fil.split("/")[-1] and urlretrieve into it. Verified on a login node: calling try_load_ncl_cmap(adf, "ncl_default") writes ncl_default.rgb into the current working directory — i.e. wherever the user launched ./run_adf_diag, usually the ADF clone itself. That's what the new *.rgb line in .gitignore is papering over. Three problems:
- ADF runs in batch on Derecho/Casper compute nodes, which generally have no outbound internet. The download will fail there, and since nothing is cached on failure it retries for every variable/season that asks for the colormap.
urlretrieveis called with no timeout, so each of those retries can block rather than fail fast. Onlyurllib.error.HTTPErroris caught explicitly.- Writing generated files into the source tree (or any CWD) is surprising and breaks read-only/shared installs.
Since ncl_defaults = ["ncl_default"] is a single-entry list, this is one small file — vendoring it under lib/ (or a configurable cache dir, defaulting somewhere writable) would remove the network dependency entirely.
Non-blocking
- Shared mutation of
variable_defaults.AdfObs.variable_defaultsreturnscopy.copy(...)— a shallow copy, so the per-variable sub-dicts are shared.vres = adfobj.variable_defaults.get(var, {})aliases the master entry, andadd_var_to_vresplusvres["plot_type"]/vres["vector"]/vres["umdlfld_nowrap"] = ...write straight into it. Afterglobal_latlon_vect_mapruns, the master entry forUpermanently carriesvector: Trueand the season'sumdlfld_nowrap/vobsfld_nowrapDataArrays, which then flow as**vresinto later scripts. Each script does overwriteplot_typeandvar_name, so I did not find a visible failure from this today — but it's a live footgun and holds references to full fields. Acopy.deepcopyofvres(or havingadd_var_to_vresreturn a new dict) closes it. - Dead code, one piece of it broken.
load_colormap(494) andchoose_colormap_type(485) are never called.load_colormap:503callsread_ncl_colormap(adfobj, locfil, msg)— three arguments to a two-parameter function; confirmedTypeError: read_ncl_colormap() takes 2 positional arguments but 3 were givenas soon as a local.rgbexists. It also says "Defaulting to 'coolwarm'" while returning'viridis', and unpackscm, cmr = ncl_to_mpl(...)whenncl_to_mplcan return a bareNone. Either delete these two or fix and wire them up. try_load_ncl_cmap(513-538). Line 536 ismsg + "Something went wrong..."— an expression statement, so that message never reaches the debug log (msg +=intended). If the innerurllib.error.HTTPErrorbranch fires,datais never assigned and the followingisinstance(data, ...)raisesUnboundLocalError, which the outer bareexcept Exceptionthen swallows — it lands on 'viridis' by accident rather than by design.- NCL support is narrower than the PR description suggests.
ncl_defaults = ["ncl_default"], andget_cmaponly attempts an NCL lookup when the name is exactly"ncl_default". Any other NCL table name (BlueRed,amwg, …) falls through the final validity check and is replaced by the default colormap. Worth either generalizing or saying so in the docs. meridional_mean.py:181,183,210— new warning text says "zonal mean plotting skipped" in the meridional script. Copy-paste fromzonal_mean.py; user-visible.- Stale module docstring,
lib/plotting_functions.py:1-30. It still documentsplot_map_vect_and_save, which this PR deletes, and still shows the old signatures formake_polar_plot/plot_map_and_save/plot_zonal_mean_and_save/plot_meridional_mean_and_savewithout the new leadingadfobjargument.AGENTS.md§7.6. - Docstrings on new helpers (
AGENTS.md§6.2). 7 of the 11 new functions inplotting_utils.pyhave no docstring at all (guess_ncl_url,download_ncl_colormap,read_ncl_colormap,ncl_to_mpl,choose_colormap_type,load_colormap,add_var_to_vres); the other 4 are one-liners with noParameters/Returnssections. None were added to the module docstring'sFunctionslist.add_var_to_vresandresolve_levelsin particular have non-obvious contracts that need documenting. - New variable-defaults keys are undocumented. The header comment block in
adf_variable_defaults.yamlisn't updated fornickname,colormap_2d,contour_levels_linspace, or the new nested per-plot-type blocks (polar_map:,global_latlon_map:,zonal_mean:) with theirnh/sh/level sub-keys — the headline feature of this PR.AGENTS.md§4.3 asks for new fields to be documented there. Right now those forms exist only in the PR description; the shipped file usesnicknameonce and none of the rest, so nothing exercises them. black(AGENTS.md§6.1). Intersectingblack --check --diffhunks with the lines this PR adds or changes gives roughly 1,333 new/changed lines that black would reformat, concentrated inplotting_functions.py(~640),plotting_utils.py(~408) andmeridional_mean.py(~189).resolve_levels(620) is also indented as if it were a method — its whole body sits at 8 spaces.env/npl-2024a_environment.yaml— a new 649-line fully-pinned environment, unrelated to plotting enhancements, with no README mention. Intentional, or a leftover? It's a real maintenance surface (AGENTS.md§4.5).
Optional / follow-up
- Removing
pf.plot_map_vect_and_saveis right for the codebase, but it's a public helper that user-maintained scripts may call. Worth a line in the release notes. - The PR adds 8 new
"$\mathbf{...}$"string literals; these are not raw strings and produceSyntaxWarning: invalid escape sequence '\m'on import under Python 3.12+. Pre-existing pattern (24 occurrences onmain), but new code could user"...". cm.get_cmapatplotting_utils.py:795is deprecated. I checked: it still works in the pinned matplotlib 3.9.4 (and 3.10), so this is not urgent — but since the block was rewritten anyway,plt.get_cmap/mpl.colormaps[...]would be cheap to adopt.- The vector percent-difference is computed for every season and level (
global_latlon_vect_map.py) and then discarded atplotting_functions.py:527(if vector: #ignore percent diff for now). Skipping the computation would save the work and make the intent clearer. read_ncl_colormap:414setsis_url = False, which is never used.
Not verified
- The plots themselves. I did not run the ADF against real CAM output, so the visual claims in the PR description (colorbar fit, extend behaviour, zonal/meridional styling,
colormap_2d) are unverified — as are the new contour-level and colormap choices as science, which need a CAM/AMWG reviewer's eye rather than mine. - Whether Derecho/Casper compute nodes can reach
ncl.ucar.edu. The download succeeded from a login node; the compute-node behaviour behind blocking finding 4 is my expectation, not something I tested. pytest lib/test/unit_tests— pytest isn't installed in the localadf_v1.0.0env, so I couldn't run it. No file in this PR is in the suite's coverage, so I'd expect it to pass unchanged. YAML parses cleanly for all files (check-yamlequivalent), and every changed.pyfile compiles.
New feature type
New plot and/or plot enhancement
What is this new feature?
Add flexibility for plotting (contour levels, colormap, etc.) based on pressure level and/or plot type (polar vs global)
Update plots for more professional look, ie Zonal and Meridional are very bare boned, lat/lon were missing units, some variables were not getting proper scaling, fit colorbar better to axes and extend if necessary, etc.
colormap_2d)Combine Lat/Lon Vector into Lat/Lon code since it was a lot of reused code.
Add option in variable defaults config for variable nickname; remove all percent diff args in defaults yaml and hard code in plotting script. The user will still have the option to supply these args in the yaml file if default is not desired.
Add option for old NCL color maps
Add units cleaning method to ensure units are always available, this was not enforced throughout the code base.
Add code to resolve levels for hemisphere and resolve colormap in
prep_contour_plotCore new features:
Different contours based on vertical levels
closes #452
closes #425
closes #373