@@ -158,17 +158,35 @@ def _concentration_unit(species_name: str) -> str:
158158# properties_df, available_species) and returns a 1-D numpy array
159159# along the time axis (or None if the recipe can't be computed against
160160# the species set FaIR actually ran).
161+ def _forcing_to_numpy (forcing_da ):
162+ """
163+ Materialise FaIR's forcing DataArray as a plain numpy array once.
164+
165+ Returns ``(forcing_np, species_index)`` where ``forcing_np`` has axis
166+ order ``(timebounds, scenario, config, specie)`` and ``species_index``
167+ maps species name -> position on the last axis. Extracting/aggregating
168+ per (scenario, member) against this numpy array with integer indexing
169+ is orders of magnitude faster than repeated xarray ``.sel().isel()``
170+ orthogonal indexing (the dominant cost for large ensembles), and is
171+ numerically identical (same species order, same summation order).
172+ """
173+ da = forcing_da .transpose ("timebounds" , "scenario" , "config" , "specie" )
174+ species = list (da ["specie" ].values )
175+ return np .asarray (da .values ), {name : i for i , name in enumerate (species )}
176+
177+
161178def _sum_forcing_over (
162- forcing_da , sc_idx : int , member_offset : int , species_to_sum : list [str ]
179+ forcing_np ,
180+ species_index : "dict[str, int]" ,
181+ sc_idx : int ,
182+ member_offset : int ,
183+ species_to_sum : list [str ],
163184) -> np .ndarray :
164- """Helper: sum FaIR's forcing array over a list of species names."""
165- species_present = [s for s in species_to_sum if s in forcing_da [ "specie" ]. values ]
166- if not species_present :
185+ """Sum the forcing array over a list of species names (numpy path) ."""
186+ idx = [species_index [ s ] for s in species_to_sum if s in species_index ]
187+ if not idx :
167188 return None
168- sliced = forcing_da .sel (specie = species_present ).isel (
169- scenario = sc_idx , config = member_offset
170- )
171- return sliced .sum (dim = "specie" ).values
189+ return forcing_np [:, sc_idx , member_offset , idx ].sum (axis = - 1 )
172190
173191
174192def _species_by_property (properties_df , predicate ) -> list [str ]:
@@ -179,20 +197,21 @@ def _species_by_property(properties_df, predicate) -> list[str]:
179197
180198
181199def _build_forcing_aggregations ( # noqa: PLR0912, PLR0915
182- forcing_da , sc_idx : int , member_offset : int , properties_df
200+ forcing_np , species_index , species_in_run , sc_idx : int ,
201+ member_offset : int , properties_df ,
183202) -> "dict[str, tuple[np.ndarray, str]]" :
184203 """
185204 Build the value arrays for the supported forcing aggregations.
186205
187206 Returns a dict keyed by openscm-runner variable name; values are
188207 ``(values, unit)`` tuples. Aggregations whose species list does
189208 not intersect FaIR's actual species are omitted, not zero-filled.
209+ Operates on the pre-materialised numpy forcing array (see
210+ :func:`_forcing_to_numpy`) for speed.
190211 """
191- species_in_run = list (forcing_da ["specie" ].values )
192-
193212 def sum_over (species_list : list [str ]) -> np .ndarray :
194213 return _sum_forcing_over (
195- forcing_da , sc_idx , member_offset , species_list
214+ forcing_np , species_index , sc_idx , member_offset , species_list
196215 )
197216
198217 ghgs = _species_by_property (
@@ -230,11 +249,7 @@ def maybe(name: str, values):
230249 out [name ] = (values , forcing_unit )
231250
232251 # Anthropogenic = total forcing minus Solar minus Volcanic
233- total = (
234- forcing_da .isel (scenario = sc_idx , config = member_offset )
235- .sum (dim = "specie" )
236- .values
237- )
252+ total = forcing_np [:, sc_idx , member_offset , :].sum (axis = - 1 )
238253 natural_species = [s for s in ("Solar" , "Volcanic" ) if s in species_in_run ]
239254 if natural_species :
240255 natural = sum_over (natural_species )
@@ -283,13 +298,9 @@ def maybe(name: str, values):
283298 "Effective Radiative Forcing|Solar" : "Solar" ,
284299 }
285300 for openscm_name , fair_specie in single_specie_aliases .items ():
286- if fair_specie not in species_in_run :
301+ if fair_specie not in species_index :
287302 continue
288- values = (
289- forcing_da .sel (specie = fair_specie )
290- .isel (scenario = sc_idx , config = member_offset )
291- .values
292- )
303+ values = forcing_np [:, sc_idx , member_offset , species_index [fair_specie ]]
293304 maybe (openscm_name , values )
294305
295306 # RCMIP-aligned hierarchical aggregations. Most are aliases for
@@ -367,12 +378,8 @@ def maybe(name: str, values):
367378 for spec_leaf , spec in (
368379 ("CO2" , "CO2" ), ("CH4" , "CH4" ), ("N2O" , "N2O" ),
369380 ):
370- if spec in species_in_run :
371- v = (
372- forcing_da .sel (specie = spec )
373- .isel (scenario = sc_idx , config = member_offset )
374- .values
375- )
381+ if spec in species_index :
382+ v = forcing_np [:, sc_idx , member_offset , species_index [spec ]]
376383 maybe (f"Effective Radiative Forcing|Anthropogenic|{ spec_leaf } " , v )
377384
378385 return out
@@ -406,6 +413,9 @@ def extract_outputs( # noqa: PLR0913, PLR0912, PLR0915
406413 rows : list = []
407414
408415 species_in_run = list (f .forcing ["specie" ].values )
416+ # Materialise the forcing array once (dominant cost otherwise is
417+ # repeated xarray orthogonal indexing per member); aggregate in numpy.
418+ forcing_np , forcing_species_index = _forcing_to_numpy (f .forcing )
409419
410420 # Pre-compute aggregations once per (scenario, member) since the
411421 # recipes share intermediate sums.
@@ -415,7 +425,8 @@ def extract_outputs( # noqa: PLR0913, PLR0912, PLR0915
415425
416426 aggregations = (
417427 _build_forcing_aggregations (
418- f .forcing , sc_idx , member_offset , properties_df
428+ forcing_np , forcing_species_index , species_in_run ,
429+ sc_idx , member_offset , properties_df ,
419430 )
420431 if properties_df is not None
421432 else {}
0 commit comments