11import marimo
22
3- __generated_with = "0.23.6 "
3+ __generated_with = "0.23.16 "
44app = marimo .App ()
55
66with app .setup :
99 import altair as alt
1010 import plotting .ocf_theme # noqa: F401 — registers OCF Altair theme as side effect
1111 import polars as pl
12+ from contracts .weather_schemas import Nwp
1213 from plotting .ocf_theme import GRID , ORANGE_RED
1314
1415
1516@app .cell
1617def _ ():
17- # TODO: Load polars DataFrame from local Delta Table of NWP data.
18- return
18+ df = Nwp . scan_delta (). drop ( "nwp_model_id" )
19+ return ( df ,)
1920
2021
2122@app .cell
@@ -38,71 +39,83 @@ def _():
3839 return (nwp_vars ,)
3940
4041
41- @app .cell
42- def _ (df , nwp_vars ):
43- def plot_null_distribution (df , target_init_time , target_h3_index , nwp_vars ):
44- # 1. Filter down to the specific init_time and h3_index
45- filtered = df .filter (
46- (pl .col ("init_time" ) == target_init_time ) & (pl .col ("h3_index" ) == target_h3_index )
47- )
42+ @app .function
43+ def plot_null_distribution (df , target_init_time , target_h3_index , nwp_vars ):
44+ """Chart where `nwp_vars` is missing, one row per ensemble member.
4845
49- # 2. Unpivot (melt) the data so we can plot all variables on a single Y-axis
50- melted = filtered .unpivot (
51- on = nwp_vars ,
52- index = ["valid_time" , "ensemble_member" ],
53- variable_name = "variable" ,
54- value_name = "value" ,
55- )
46+ Args:
47+ df: Lazy scan of the NWP Delta table.
48+ target_init_time: The single NWP run to plot.
49+ target_h3_index: The single H3 cell to plot.
50+ nwp_vars: The NWP variable name (or list of names) to plot.
51+ """
52+ # 1. Filter down to the specific init_time and h3_index
53+ filtered = df .filter (
54+ (pl .col ("init_time" ) == target_init_time ) & (pl .col ("h3_index" ) == target_h3_index )
55+ )
5656
57- # 3. Create a boolean flag for missing data and a combined label for the Y-axis
58- plot_df = melted .with_columns (
59- # Check for both database Nulls and float NaNs
60- is_missing = pl .col ("value" ).is_null () | pl .col ("value" ).is_nan (),
61- # Create a string like "temperature_2m (Member 0)":
62- # row_label=(
63- # pl.col("variable") + " (Member " + pl.col("ensemble_member").cast(pl.Utf8) + ")"
64- # ),
65- row_label = pl .col ("ensemble_member" ),
66- )
57+ # 2. Unpivot (melt) the data so we can plot all variables on a single Y-axis
58+ melted = filtered .unpivot (
59+ on = nwp_vars ,
60+ index = ["valid_time" , "ensemble_member" ],
61+ variable_name = "variable" ,
62+ value_name = "value" ,
63+ )
6764
68- # 5. Build the Altair Chart
69- base = alt .Chart (plot_df ).encode (
70- y = alt .Y ("row_label:N" , title = "Ensemble Member" , sort = "ascending" )
71- )
65+ # 3. Create a boolean flag for missing data and a combined label for the Y-axis. Altair
66+ # only accepts materialised data, so collect once the filter has cut the scan down to a
67+ # single NWP run and a single H3 cell.
68+ plot_df = melted .with_columns (
69+ # Check for both database Nulls and float NaNs
70+ is_missing = pl .col ("value" ).is_null () | pl .col ("value" ).is_nan (),
71+ # Create a string like "temperature_2m (Member 0)":
72+ # row_label=(
73+ # pl.col("variable") + " (Member " + pl.col("ensemble_member").cast(pl.Utf8) + ")"
74+ # ),
75+ row_label = pl .col ("ensemble_member" ),
76+ ).collect ()
77+
78+ # 5. Build the Altair Chart
79+ base = alt .Chart (plot_df ).encode (
80+ y = alt .Y ("row_label:N" , title = "Ensemble Member" , sort = "ascending" )
81+ )
7282
73- # Layer 1: A light gray background line showing the full time series extent
74- background_line = base .mark_line (color = GRID , strokeWidth = 1 )
75- background_lines = background_line .encode ( # ty: ignore[unresolved-attribute]
76- x = alt .X ("valid_time:T" , title = "Valid Time" ),
77- detail = "row_label:N" , # Ensures lines don't connect across different rows
78- )
83+ # Layer 1: A light gray background line showing the full time series extent
84+ background_line = base .mark_line (color = GRID , strokeWidth = 1 )
85+ background_lines = background_line .encode ( # ty: ignore[unresolved-attribute]
86+ x = alt .X ("valid_time:T" , title = "Valid Time" ),
87+ detail = "row_label:N" , # Ensures lines don't connect across different rows
88+ )
7989
80- # Layer 2: Red ticks superimposed exactly where the data is missing
81- missing_marks = (
82- base .transform_filter (alt .datum .is_missing )
83- .mark_tick (
84- color = ORANGE_RED ,
85- thickness = 3 , # Make the red mark stand out
86- size = 12 , # Height of the tick mark
87- )
88- .encode (x = "valid_time:T" ) # ty: ignore[unresolved-attribute] # astral-sh/ty#2520
90+ # Layer 2: Red ticks superimposed exactly where the data is missing
91+ missing_marks = (
92+ base .transform_filter (alt .datum .is_missing )
93+ .mark_tick (
94+ color = ORANGE_RED ,
95+ thickness = 3 , # Make the red mark stand out
96+ size = 12 , # Height of the tick mark
8997 )
98+ .encode (x = "valid_time:T" ) # ty: ignore[unresolved-attribute] # astral-sh/ty#2520
99+ )
90100
91- # Combine the layers and configure the chart size
92- return (
93- (background_lines + missing_marks )
94- .properties (
95- title = (
96- f"Missing NWP Data | init_time: { target_init_time .strftime ('%Y-%m-%d' )} "
97- f" | { nwp_vars } | H3: { target_h3_index } "
98- ),
99- width = 800 ,
100- # Dynamically scales chart height based on the number of rows
101- height = alt .Step (10 ),
102- )
103- .configure_axis (labelFontSize = 11 , titleFontSize = 13 )
101+ # Combine the layers and configure the chart size
102+ return (
103+ (background_lines + missing_marks )
104+ .properties (
105+ title = (
106+ f"Missing NWP Data | init_time: { target_init_time .strftime ('%Y-%m-%d' )} "
107+ f" | { nwp_vars } | H3: { target_h3_index } "
108+ ),
109+ width = 800 ,
110+ # Dynamically scales chart height based on the number of rows
111+ height = alt .Step (10 ),
104112 )
113+ .configure_axis (labelFontSize = 11 , titleFontSize = 13 )
114+ )
115+
105116
117+ @app .cell
118+ def _ (df , nwp_vars ):
106119 chart = plot_null_distribution (
107120 df ,
108121 target_init_time = datetime (2026 , 5 , 1 , tzinfo = UTC ),
@@ -113,5 +126,37 @@ def plot_null_distribution(df, target_init_time, target_h3_index, nwp_vars):
113126 return
114127
115128
129+ @app .function
130+ def test_plot_null_distribution_flags_nulls_and_nans ():
131+ init_time = datetime (2026 , 5 , 1 , tzinfo = UTC )
132+ other_init_time = datetime (2026 , 5 , 2 , tzinfo = UTC )
133+ h3_index = 599148110664433663
134+ other_h3_index = 599148110664433662
135+ # The last two readings are missing too, but belong to another NWP run and another H3 cell, so
136+ # the filter must drop them rather than plot them.
137+ readings = pl .LazyFrame (
138+ {
139+ "init_time" : [init_time ] * 4 + [other_init_time , init_time ],
140+ "valid_time" : [datetime (2026 , 5 , 1 , hour = h , tzinfo = UTC ) for h in range (6 )],
141+ "ensemble_member" : [0 , 0 , 0 , 0 , 0 , 0 ],
142+ "h3_index" : [h3_index ] * 5 + [other_h3_index ],
143+ "temperature_2m" : pl .Series (
144+ [1.0 , None , float ("nan" ), 4.0 , None , None ], dtype = pl .Float32
145+ ),
146+ }
147+ )
148+
149+ chart = plot_null_distribution (
150+ readings ,
151+ target_init_time = init_time ,
152+ target_h3_index = h3_index ,
153+ nwp_vars = "temperature_2m" ,
154+ )
155+
156+ # Altair inlines the chart's data as its single named dataset.
157+ (rows ,) = chart .to_dict ()["datasets" ].values ()
158+ assert [row ["is_missing" ] for row in rows ] == [False , True , True , False ]
159+
160+
116161if __name__ == "__main__" :
117162 app .run ()
0 commit comments