@@ -300,3 +300,139 @@ def merge(self, other):
300300 # Assign merged edges to merged groups
301301 merged_groups .group_edges = list (merged_edges )
302302 return merged_groups
303+
304+
305+ def convert_flux_groups (flux , source_groups , target_groups ):
306+ """Convert flux spectrum between energy group structures.
307+
308+ Uses flux-per-unit-lethargy conservation, which assumes constant flux per
309+ unit lethargy within each source group and distributes flux to target
310+ groups proportionally to their lethargy width.
311+
312+ .. versionadded:: 0.15.4
313+
314+ Parameters
315+ ----------
316+ flux : Iterable of float
317+ Flux values for source groups. Length must equal
318+ source_groups.num_groups.
319+ source_groups : EnergyGroups or str
320+ Energy group structure of the input flux with boundaries in [eV].
321+ Can be an EnergyGroups instance or the name of a group structure
322+ (e.g., 'CCFE-709').
323+ target_groups : EnergyGroups or str
324+ Target energy group structure with boundaries in [eV]. Can be an
325+ EnergyGroups instance or the name of a group structure
326+ (e.g., 'UKAEA-1102').
327+
328+ Returns
329+ -------
330+ numpy.ndarray
331+ Flux values for target groups. Total flux is conserved for
332+ overlapping energy regions.
333+
334+ Raises
335+ ------
336+ TypeError
337+ If source_groups or target_groups is not EnergyGroups or str
338+ ValueError
339+ If flux length doesn't match source_groups, or flux contains
340+ negative, NaN, or infinite values
341+
342+ See Also
343+ --------
344+ EnergyGroups : Energy group structure class
345+
346+ Notes
347+ -----
348+ The assumption of constant flux per unit lethargy within each source
349+ group is physically reasonable for most reactor spectra but is not
350+ exact. For best accuracy, use source spectra with sufficiently fine
351+ energy resolution.
352+
353+ Examples
354+ --------
355+ Convert FNS 709-group flux to UKAEA-1102 structure:
356+
357+ >>> import numpy as np
358+ >>> flux_709 = np.load('tests/fns_flux_709.npy')
359+ >>> flux_1102 = openmc.mgxs.convert_flux_groups(flux_709, 'CCFE-709', 'UKAEA-1102')
360+
361+ Convert using EnergyGroups instances:
362+
363+ >>> source = openmc.mgxs.EnergyGroups([1.0, 10.0, 100.0])
364+ >>> target = openmc.mgxs.EnergyGroups([1.0, 5.0, 10.0, 50.0, 100.0])
365+ >>> flux_target = openmc.mgxs.convert_flux_groups([1e8, 2e8], source, target)
366+
367+ References
368+ ----------
369+ .. [1] J. J. Duderstadt and L. J. Hamilton, "Nuclear Reactor Analysis,"
370+ John Wiley & Sons, 1976.
371+ .. [2] M. Fleming and J.-Ch. Sublet, "FISPACT-II User Manual,"
372+ UKAEA-R(18)001, UK Atomic Energy Authority, 2018. See GRPCONVERT keyword.
373+
374+ """
375+ # Handle string group structure names
376+ if isinstance (source_groups , str ):
377+ source_groups = EnergyGroups (source_groups )
378+ if isinstance (target_groups , str ):
379+ target_groups = EnergyGroups (target_groups )
380+
381+ # Type validation
382+ cv .check_type ('source_groups' , source_groups , EnergyGroups )
383+ cv .check_type ('target_groups' , target_groups , EnergyGroups )
384+
385+ # Convert flux to numpy array
386+ flux = np .asarray (flux , dtype = np .float64 )
387+ if flux .ndim != 1 :
388+ raise ValueError (f'flux must be 1-dimensional, got shape { flux .shape } ' )
389+
390+ # Validate flux length matches source groups
391+ if len (flux ) != source_groups .num_groups :
392+ raise ValueError (
393+ f'Length of flux ({ len (flux )} ) must equal number of source '
394+ f'groups ({ source_groups .num_groups } )'
395+ )
396+
397+ # Check for invalid flux values
398+ if np .any (np .isnan (flux )):
399+ raise ValueError ('flux contains NaN values' )
400+ if np .any (np .isinf (flux )):
401+ raise ValueError ('flux contains infinite values' )
402+ if np .any (flux < 0 ):
403+ raise ValueError ('flux values must be non-negative' )
404+
405+ # Get energy edges
406+ source_edges = source_groups .group_edges
407+ target_edges = target_groups .group_edges
408+ num_target = target_groups .num_groups
409+
410+ # Initialize output array
411+ flux_target = np .zeros (num_target )
412+
413+ # Main conversion loop: distribute flux using lethargy weighting
414+ for idx_src , flux_src in enumerate (flux ):
415+ if flux_src == 0 :
416+ continue
417+
418+ e_low_src = source_edges [idx_src ]
419+ e_high_src = source_edges [idx_src + 1 ]
420+ lethargy_src = np .log (e_high_src / e_low_src )
421+
422+ for idx_tgt in range (num_target ):
423+ e_low_tgt = target_edges [idx_tgt ]
424+ e_high_tgt = target_edges [idx_tgt + 1 ]
425+
426+ # Skip non-overlapping groups
427+ if e_high_tgt <= e_low_src or e_low_tgt >= e_high_src :
428+ continue
429+
430+ # Calculate overlap region
431+ e_low_overlap = max (e_low_src , e_low_tgt )
432+ e_high_overlap = min (e_high_src , e_high_tgt )
433+ lethargy_overlap = np .log (e_high_overlap / e_low_overlap )
434+
435+ # Distribute flux proportionally to lethargy fraction
436+ flux_target [idx_tgt ] += flux_src * (lethargy_overlap / lethargy_src )
437+
438+ return flux_target
0 commit comments