diff --git a/CHANGELOG b/CHANGELOG index 76e8ccbe..509ff908 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,10 @@ +v3.9.6 2024-08-30 + * Add several new convenience methods to MiniMC results and histograms. + * Fix bug in MiniMC histogram error propagation. + * NCMATComposer gets new expert-only methods for adding @CUSTOM_ sections + or raw text to NCMAT data. + * NCMATComposer.from_hfg sets plotlabel from title. + v3.9.5 2024-08-28 * Make ncrystal_hfg2ncmat functionality available in Python API as well in the new NCrystal.hfg2ncmat module. Also add NCMATComposer.from_hfg(..) diff --git a/CMakeLists.txt b/CMakeLists.txt index fc6d84d7..6cb672bf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,7 +86,7 @@ endif() cmake_policy( SET CMP0048 NEW )#Not sure if this is really needed -project( NCrystal VERSION 3.9.5 ${_project_metadata} ) +project( NCrystal VERSION 3.9.6 ${_project_metadata} ) unset( _project_metadata ) diff --git a/NCrystal/__init__.py b/NCrystal/__init__.py index 1b72a119..836c5f16 100644 --- a/NCrystal/__init__.py +++ b/NCrystal/__init__.py @@ -54,7 +54,7 @@ #NB: Synchronize meta-data below with fields in setup.py+template_setup.py.in meta data: __license__ = "Apache 2.0, http://www.apache.org/licenses/LICENSE-2.0" -__version__ = '3.9.5' +__version__ = '3.9.6' __status__ = "Production" __author__ = "NCrystal developers (Thomas Kittelmann, Xiao Xiao Cai)" __copyright__ = "Copyright 2015-2024 %s"%__author__ diff --git a/NCrystal/_cli_hfg2ncmat.py b/NCrystal/_cli_hfg2ncmat.py index 453b8e32..a100abd7 100644 --- a/NCrystal/_cli_hfg2ncmat.py +++ b/NCrystal/_cli_hfg2ncmat.py @@ -72,13 +72,13 @@ def _parseArgs( default_debye_temp ): parser = argparse.ArgumentParser(description=descr, formatter_class=RawTextHelpFormatter) parser.add_argument("--output",'-o',default='autogen.ncmat',type=str, - help=(f"Output file name (defaults to autogen.ncmat)." + help=("Output file name (defaults to autogen.ncmat)." " Can be stdout.")) parser.add_argument('--force',action='store_true', - help=(f"Will overwrite existing file " + help=("Will overwrite existing file " "if it already exists.")) parser.add_argument("--spec",'-s',metavar='SPEC',type=str,required=True, - help=f"Hydrogen binding specification (see above).") + help="Hydrogen binding specification (see above).") parser.add_argument("--formula",'-f',metavar='FORMULA', type=str, required=True, @@ -87,7 +87,7 @@ def _parseArgs( default_debye_temp ): parser.add_argument("--density",'-d',metavar='DENSITY', type=float, required=True, - help=f"Material density in g/cm3.") + help="Material density in g/cm3.") parser.add_argument("--debyetemp",metavar='VALUE', type=float, default=default_debye_temp, @@ -98,7 +98,7 @@ def _parseArgs( default_debye_temp ): ' comment near top of output file). Use \\n ' 'for line-breaks.')) parser.add_argument('--notrim',action='store_true', - help=f"No trimming of resulting VDOS curve.") + help="No trimming of resulting VDOS curve.") args=parser.parse_args() return args diff --git a/NCrystal/_mmc.py b/NCrystal/_mmc.py index 1b3126b0..87c6a447 100644 --- a/NCrystal/_mmc.py +++ b/NCrystal/_mmc.py @@ -111,6 +111,96 @@ def clone( self, rebin_factor = 1 ): c.rebin( rebin_factor ) return c + def integrate( self, xlow, xhigh, tolerance = 1e-5 ): + """ + Returns integrated contents of the histogram over the area + [xlow,xhigh] along with the error of that value in a tuple + (content,error). + + This is done translating xlow and xhigh to exact bin edges and then + calling integrate_bins. If that is not possible within the + tolerance, an exception is raised. + + """ + if not ( xhigh >= xlow ): + from .exceptions import NCBadInput + raise NCBadInput('Invalid integration range requested.') + + bw = self.binwidth + def _findedge(x): + if x <= self.__xmin: + return 0 + if x >= self.__xmax: + return self.nbins + r = ( x - self.__xmin ) / bw + ir = int(r+0.5) + if abs(r-ir) > tolerance: + from .exceptions import NCBadInput + raise NCBadInput(f'Value {x} does not correspond exactly' + ' to a bin edge within the tolerance.') + return ir + e_low = _findedge(xlow) + e_high = _findedge(xhigh) + if e_low == e_high: + return ( 0.0, 0.0 ) + assert e_low >= 0 + assert e_low < e_high < self.nbins + return self.integrate_bins( e_low, e_high - 1 ) + + def integrate_bins( self, bin_low = None, bin_up = None ): + """ + Returns integrated contents of the bins [bin_low,bin_up[ along with + the error of that value in a tuple (content,error). + + If bin_low is None the integration will start at the first bin and + include the underflow bin. + + If bin_up is None the integration will end at the last bin and + include the overflow bin. + """ + + add_overflow, add_underflow = False, False + if bin_low is None: + add_underflow = True + bin_low = 0 + underflow_c = self.stats.get('underflow') + underflow_e2 = self.stats.get('underflow_errorsq') + if bool(underflow_c is None) != bool(underflow_e2 is None): + from .exceptions import NCBadInput + raise NCBadInput('Inconsistent underflow info') + if underflow_c is None: + add_underflow = False + + if bin_up is None: + add_overflow = True + bin_up = self.__nbins + overflow_c = self.stats.get('overflow') + overflow_e2 = self.stats.get('overflow_errorsq') + if bool(overflow_c is None) != bool(overflow_e2 is None): + from .exceptions import NCBadInput + raise NCBadInput('Inconsistent overflow info') + if overflow_c is None: + add_overflow = False + + bin_low, bin_up = int(bin_low), int(bin_up) + if bin_up < bin_low or bin_low<0 or bin_up > self.__nbins: + from .exceptions import NCBadInput + raise NCBadInput('Invalid bin range requested') + content_integral = self.__y[bin_low:bin_up].sum() + if add_underflow: + content_integral += underflow_c + if add_overflow: + content_integral += overflow_c + if self.__yerrsq is None: + #unweighted, just base erros on contents: + return ( content_integral, _np.sqrt(content_integral) ) + errorsq_integral = self.__yerrsq[bin_low:bin_up].sum() + if add_underflow: + errorsq_integral += underflow_e2 + if add_overflow: + errorsq_integral += overflow_e2 + return ( content_integral, _np.sqrt(errorsq_integral) ) + def add_contents( self, other_hist ): o = other_hist assert self.__xmin == o.__xmin @@ -124,12 +214,12 @@ def add_contents( self, other_hist ): if o.__yerrsq is None: pass#done else: - self.__yerrsq = o.__yerrsq + self.__yerrsq = self.__y + o.__yerrsq else: if o.__yerrsq is None: - self.__yerrsq = o.__y + self.__yerrsq += o.__y else: - self.__yerrsq = o.__yerrsq + self.__yerrsq += o.__yerrsq def rebin( self, rebin_factor ): assert self.__nbins % rebin_factor == 0 @@ -151,11 +241,15 @@ def stats( self ): @property def errors( self ): if self.__yerr is None: - self.__yerr = _np.sqrt( self.__yerrsq - if self.__yerrsq is not None - else self.__y ) + self.__yerr = _np.sqrt( self.errors_squared ) return self.__yerr + @property + def errors_squared( self ): + return ( self.__yerrsq + if self.__yerrsq is not None + else self.__y ) + @property def content( self ): return self.__y @@ -286,6 +380,33 @@ def __init__( self, def histograms( self ): return self.__hists + @property + def histogram_main( self ): + return self.__hists[0] + + @property + def histogram_breakdown( self ): + return dict( (h.title,h) for h in self.__hists[1:] ) + + def histogram_sum( self, *, select=None, exclude=None ): + if isinstance(exclude,str): + exclude=[exclude] + if isinstance(select,str): + select=[select] + hl = self.__hists[1:] + if not exclude and not select: + return self.histogram_main + if select: + hl = [ h for h in hl if h.title in select ] + if exclude: + hl = [ h for h in hl if h.title not in exclude ] + if len(hl) <= 1: + return hl[0] if hl else None + h = hl[0].clone() + for o in hl[1:]: + h.add_contents( o ) + return h + @property def cfgstr( self ): return self.__cfgstr @@ -606,8 +727,6 @@ def quick_diffraction_pattern( cfgstr, *, mfp_scatter = 1/macroxs_scatter if macroxs_scatter else float('inf') sphere_diameter = _parse_length(material_thickness, mfp=mfp_scatter) - #print(f'mfp_scatter = {mfp_scatter/unit_cm:g} cm') - #print(f'sphere_diameter = {sphere_diameter/unit_cm:g} cm') def simfct( n, cfgstr ): import time diff --git a/NCrystal/_ncmatimpl.py b/NCrystal/_ncmatimpl.py index d8328350..d78c920e 100644 --- a/NCrystal/_ncmatimpl.py +++ b/NCrystal/_ncmatimpl.py @@ -70,6 +70,67 @@ def add_hard_sphere_sans_model( self, sphere_radius ): self.__dirty() self.__params['custom_hardspheresans'] = float( sphere_radius ) + def add_raw_content( self, content ): + if not isinstance(content,str): + raise _nc_core.NCBadInput('Invalid raw content (must be a string)') + if not content: + return + self.__dirty() + if 'raw_content' in self.__params: + self.__params['raw_content'] += str(content) + else: + self.__params['raw_content'] = str(content) + + def get_raw_content( self ): + return self.__params.get('raw_content','') + + def clear_raw_content( self ): + if 'raw_content' in self.__params: + self.__dirty() + del self.__params['raw_content'] + + def get_custom_section_data( self, section_name ): + if section_name is None: + dd = self.__params.get('custom_sections') + return copy.deepcopy(dd) if dd else {} + _check_valid_custom_section_name(section_name) + if 'custom_sections' in self.__params: + return self.__params['custom_sections'].get(section_name) + + def set_custom_section_data( self, section_name, content ): + if section_name == 'HARDSPHERESANS': + raise _nc_core.NCBadInput('For HARDSPHERESANS use the' + ' .add_hard_sphere_sans_model() method' + ' instead of set_custom_section_data.') + if section_name == 'UNOFFICIALHACKS': + raise _nc_core.NCBadInput('Do not set the @CUSTOM_UNOFFICIALHACKS' + ' content directly with ' + 'set_custom_section_data.') + _check_valid_custom_section_name(section_name) + if not isinstance(content,str): + raise _nc_core.NCBadInput('Invalid custom section data' + ' content (must be a string)') + if self.__params.get('custom_sections',{}).get(section_name) == content: + return + self.__dirty() + if 'custom_sections' not in self.__params: + self.__params['custom_sections'] = {} + self.__params['custom_sections'][section_name] = content + + def clear_custom_section_data( self, section_name ): + if section_name is not None: + _check_valid_custom_section_name(section_name) + if 'custom_sections' not in self.__params: + return + if section_name is None: + self.__dirty() + del self.__params['custom_sections'] + else: + if section_name not in self.__params['custom_sections']: + return + self.__dirty() + del self.__params['custom_sections'][section_name] + def __init__(self, *, data, fmt, quiet ): self._ichange = 0#increment on all changes, for downstream caching self.__loadcache = None @@ -283,7 +344,10 @@ def set_state_of_matter( self, state_of_matter ): def lock_temperature( self, value ): if value is None: + if 'temperature' not in self.__params: + return self.__params.pop('temperature',None) + self.__dirty() return v=float(value) assert v>0.0 @@ -333,7 +397,6 @@ def remap_atom( self, element_or_isotope, *composition ): for lbl, _compos in set_compos_args: self.set_composition( lbl, _compos ) - def clear_comments( self ): self.__dirty()#not strictly needed but to be safe self.__params['top_comments'] = [] @@ -1039,7 +1102,9 @@ def create_ncmat( self, *, cfg_params='', meta_data = False, custom_hardspheresans = self.__params.get('custom_hardspheresans') if custom_hardspheresans: if not secondary_phases: - raise _nc_core.NCBadInput('Material with hard-sphere SANS enabled must have at least one secondary phase added.') + raise _nc_core.NCBadInput('Material with hard-sphere SANS' + ' enabled must have at least one' + ' secondary phase added.') l += '@CUSTOM_HARDSPHERESANS\n' l += f'{custom_hardspheresans} #sphere radius in angstrom.\n' @@ -1049,12 +1114,17 @@ def create_ncmat( self, *, cfg_params='', meta_data = False, for e in unofficial_hacks: l += ' '.join(e)+'\n' + for sn,cnt in sorted(self.__params.get('custom_sections',{}).items()): + l += f'@CUSTOM_{sn}\n' + l += cnt + if not cnt.endswith('\n'): + l += '\n' + #Dyninfo lines last, since they might contain huge arrays of data, and #people might only look at the top of the file: ld, natoms_with_fallback_dyninfo = self.__lines_dyninfo( lbl_map, atompos_fractions or fractions ) l += ld - out=["NCMAT v7"] comments = copy.deepcopy(self.__params.get('top_comments',[])) @@ -1132,6 +1202,10 @@ def create_ncmat( self, *, cfg_params='', meta_data = False, out.append(' '+e.replace(_magic_two_space,' ')) out = '\n'.join(e.rstrip() for e in out)+'\n' + + raw_cnt = self.__params.get('raw_content') + if raw_cnt: + out += raw_cnt return out if md is None else ( out, md ) def _determine_dyninfo_mapping( labels, composition, dilist ): @@ -1336,6 +1410,23 @@ def _extractad( ad ): if not _builtin or _adstr != _builtin.to_atomdb_str(): o.update_atomdb( ad.description(False), _adstr ) + #Custom sections: + for nm,cnt_lists in (i.customsections or []): + if nm == 'HARDSPHERESANS': + _nc_common.warn('Ignoring @CUSTOM_HARDSPHERESANS sections in input.') + continue + if nm == 'UNOFFICIALHACKS': + _nc_common.warn('Ignoring @CUSTOM_UNOFFICIALHACKS sections in input.') + continue + if o.get_custom_section_data(nm) is not None: + raise _nc_core.NCBadInput(f'Multiple @CUSTOM_{nm} sections in input' + 'is not supported by NCMATComposer.') + cnt='' + for linedata in cnt_lists: + cnt += ' '.join(linedata) + cnt += '\n' + o.set_custom_section_data(nm,cnt) + return o def _checklabel(lbl): @@ -1966,3 +2057,12 @@ def _extractInitialHeaderCommentsFromNCMATData( ncmat_data, dedent = True ): while all( ( e.startswith(' ') or not e) for e in comments ): comments = [ e[1:] for e in comments ] return comments + +def _check_valid_custom_section_name( name ): + if ( not name + or not isinstance(name,str) + or not name.isalpha() + or not name.isupper() ): + raise _nc_core.NCBadInput(f'Invalid custom section name: "{name}" ' + '(must be non-empty and contain only' + ' capitalised letters A-Z)') diff --git a/NCrystal/hfg2ncmat.py b/NCrystal/hfg2ncmat.py index b219b7c0..5384a7a8 100644 --- a/NCrystal/hfg2ncmat.py +++ b/NCrystal/hfg2ncmat.py @@ -90,7 +90,7 @@ def is_ascii(s): if len(p)!=2: err() count,fg=p - if not fg in fg2nH: + if fg not in fg2nH: raise NCBadInput(f'Unknown functional group in "{spec}": "{fg}"') if not count.isdigit() or not 1 <= int(count) <= 10000: raise NCBadInput(f'Invalid count in "{spec}": "{count}"') diff --git a/NCrystal/misc.py b/NCrystal/misc.py index e1ce8e44..0263602d 100644 --- a/NCrystal/misc.py +++ b/NCrystal/misc.py @@ -88,7 +88,8 @@ def description( self ): @property def plotlabel( self ): - """Returns a string with either a specific plotlabel or otherwise just the usual description.""" + """Returns a string with either a specific plotlabel or otherwise just + the usual description.""" return self.__d.get('plotlabel') or self.__d['description'] def __str__(self): diff --git a/NCrystal/ncmat.py b/NCrystal/ncmat.py index 0993b888..9f68a86f 100644 --- a/NCrystal/ncmat.py +++ b/NCrystal/ncmat.py @@ -193,8 +193,10 @@ def from_hfg( spec, debyetemp = debyetemp, verbose = verbose, notrim = notrim ) - return NCMATComposer( Impl.from_ncmat( ncmat, - keep_header=True ) ) + c = NCMATComposer( Impl.from_ncmat( ncmat, + keep_header=True ) ) + c.set_plotlabel(title) + return c def __call__( self, cfg_params = None, **kwargs ): """Convenience short-cut for the .create_ncmat() method""" @@ -548,6 +550,52 @@ def add_hard_sphere_sans_model( self, sphere_radius ): """ return self.__impl.add_hard_sphere_sans_model(sphere_radius=sphere_radius) + def add_raw_content( self, content ): + """This is an experts-only method for adding raw text data to be + appended to the generated NCMAT data. Note that multiple calls to this + method will simply append more content, if you wish to remove content + again you must call clear_raw_content. + + Note that this method does not necessarily add a newline to your content + if it is missing. + + """ + return self.__impl.add_raw_content( content ) + + def clear_raw_content( self ): + """Remove any content added by calls to .add_raw_content(..)""" + return self.__impl.clear_raw_content() + + def get_raw_content( self ): + """Return any content added by calls to .add_raw_content(..). Returns an + empty string if no such content was added.""" + return self.__impl.get_raw_content() + + def set_custom_section_data( self, section_name, content ): + """Add a @CUSTOM_ section with the provided content to the + generated NCMAT data. Note that multiple calls to this method with the + same section_name will simply override the content of that custom + section. To completely remove a previously added custom section, use the + .clear_custom_section_data(..) method. + """ + return self.__impl.set_custom_section_data( section_name, content ) + + def get_custom_section_data( self, section_name = None ): + """Access any @CUSTOM_ contents which was previously added + with the .set_custom_section_data(..) method. Returns None if data for + that section was not added. If called without parameters, a dictionary + of all such data in the form { section_name : content, ... } is + returned instead. + """ + return self.__impl.get_custom_section_data( section_name ) + + def clear_custom_section_data( self, section_name = None ): + """Remove any @CUSTOM_ section previously added by calling + set_custom_section_data. Does nothing if no such section was previously + added. Calling with no arguments clears all custom section data. + """ + return self.__impl.clear_custom_section_data( section_name ) + def lock_temperature( self, value ): """ Lock the temperature of the material to the given value. This not diff --git a/VERSION b/VERSION index 11aaa068..1635d0f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.9.5 +3.9.6 diff --git a/ncrystal_core/include/NCrystal/NCVersion.hh b/ncrystal_core/include/NCrystal/NCVersion.hh index f3251168..ceb84881 100644 --- a/ncrystal_core/include/NCrystal/NCVersion.hh +++ b/ncrystal_core/include/NCrystal/NCVersion.hh @@ -39,9 +39,9 @@ #define NCRYSTAL_VERSION_MAJOR 3 #define NCRYSTAL_VERSION_MINOR 9 -#define NCRYSTAL_VERSION_PATCH 5 -#define NCRYSTAL_VERSION 3009005 /* (1000000*MAJOR+1000*MINOR+PATCH) */ -#define NCRYSTAL_VERSION_STR "3.9.5" +#define NCRYSTAL_VERSION_PATCH 6 +#define NCRYSTAL_VERSION 3009006 /* (1000000*MAJOR+1000*MINOR+PATCH) */ +#define NCRYSTAL_VERSION_STR "3.9.6" #include "NCrystal/ncapi.h" #include diff --git a/ncrystal_core/include/NCrystal/ncrystal.h b/ncrystal_core/include/NCrystal/ncrystal.h index 6355d44a..9d49aa31 100644 --- a/ncrystal_core/include/NCrystal/ncrystal.h +++ b/ncrystal_core/include/NCrystal/ncrystal.h @@ -1201,9 +1201,9 @@ extern "C" { #endif #define NCRYSTAL_VERSION_MAJOR 3 #define NCRYSTAL_VERSION_MINOR 9 -#define NCRYSTAL_VERSION_PATCH 5 -#define NCRYSTAL_VERSION 3009005 /* (1000000*MAJOR+1000*MINOR+PATCH) */ -#define NCRYSTAL_VERSION_STR "3.9.5" +#define NCRYSTAL_VERSION_PATCH 6 +#define NCRYSTAL_VERSION 3009006 /* (1000000*MAJOR+1000*MINOR+PATCH) */ +#define NCRYSTAL_VERSION_STR "3.9.6" NCRYSTAL_API int ncrystal_version(void); /* returns NCRYSTAL_VERSION */ NCRYSTAL_API const char * ncrystal_version_str(void); /* returns NCRYSTAL_VERSION_STR */