parameterConversionTest.py

You can view and download this file on Github: parameterConversionTest.py

  1#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  2# This is an EXUDYN example
  3#
  4# Details:  Behaviour record of the Python/C++ parameter conversion:
  5#           a fixed set of probe values is written into every parameter of every item and of the
  6#           simulation and visualization settings, through each access path, and the outcome is
  7#           compared with parameterConversionTestReference.txt. An outcome is the exception type
  8#           or the type, shape and value that reads back; the error messages are not recorded.
  9#           Set recordReference = True to rewrite the reference after an intended change.
 10#
 11# Author:   Johannes Gerstmayr, Claude-JG
 12# Date:     2026-09-14
 13#
 14# Copyright:This file is part of Exudyn. Exudyn is free software. You can redistribute it and/or modify it under the terms of the Exudyn license. See 'LICENSE.txt' for more details.
 15#
 16#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 17
 18import exudyn as exu
 19import exudyn.itemInterface as itemInterface
 20import numpy as np
 21import contextlib
 22import inspect
 23import io
 24import os
 25import warnings
 26
 27recordReference = False #True: write the reference file instead of comparing
 28
 29testIsActive = exu.sys.get('testIsActive', False)
 30
 31#next to this file; runTestSuite.py executes the models from their own directory
 32referenceFile = 'parameterConversionTestReference.txt'
 33if '__file__' in globals() and os.path.basename(__file__) == 'parameterConversionTest.py':
 34    referenceFile = os.path.join(os.path.dirname(os.path.abspath(__file__)), referenceFile)
 35
 36#probe values: name, value; the same set for every parameter, whatever its type
 37probes = [('neg', -1), ('zero', 0), ('int2', 2), ('real', 2.5), ('str', 'x'), ('bool', True),
 38          ('list2', [1., 2.]), ('list3', [1., 2., 3.]), ('list4', [1., 2., 3., 4.]),
 39          ('numpy3', np.array([1., 2., 3.])), ('matrix2', [[1., 0.], [0., 1.]]),
 40          ('nodeIndex', exu.NodeIndex(0)), ('objectIndex', exu.ObjectIndex(0)),
 41          ('markerList', [exu.MarkerIndex(0), exu.MarkerIndex(1)]), ('none', None)]
 42
 43itemKinds = ['Node', 'Object', 'Marker', 'Load', 'Sensor']
 44
 45#about 38000 probes end in an error, and every C++ user error is printed and written to the log
 46#file (260000 lines); both are switched off while probing (#2895)
 47writeErrorsToLogFile = exu.special.exceptions.writeErrorsToLogFile
 48exu.special.exceptions.writeErrorsToLogFile = False
 49#every C++ user error also asks inspect.getframeinfo for file and line (Stdoutput.cpp,
 50#PyGetCurrentFileInformation), which scans sys.modules and reads the source: inside the suite,
 51#after scipy, matplotlib and ngsolve were imported, probing took 9 s instead of 1 s (#2423).
 52#The messages are not recorded, so the file name is taken from the frame directly while probing
 53inspectGetFrameInfo = inspect.getframeinfo
 54inspect.getframeinfo = lambda frame, context=1: type('FrameInfo', (), {'filename': frame.f_code.co_filename})
 55printToConsole = exu.config.printToConsole
 56exu.config.printToConsole = False
 57
 58def Outcome(function):
 59    """exception class name, or type/shape/value of what function() returns"""
 60    with contextlib.redirect_stdout(io.StringIO()):
 61        try:
 62            return Describe(function())
 63        except Exception as e:
 64            return '!' + type(e).__name__
 65
 66def Describe(value):
 67    if isinstance(value, np.ndarray):
 68        text = 'numpy' + 'x'.join(str(n) for n in value.shape) + value.dtype.kind
 69        if value.size <= 4:
 70            text += '=' + '[' + ','.join(str(round(float(x), 6)) for x in value.flatten()) + ']'
 71        return text
 72    if isinstance(value, (list, tuple)):
 73        text = type(value).__name__ + str(len(value))
 74        if len(value) <= 4 and all(isinstance(x, (int, float, bool)) for x in value):
 75            text += '=' + '[' + ','.join(str(round(float(x), 6)) for x in value) + ']'
 76        elif len(value):
 77            text += ':' + type(value[0]).__name__
 78        return text
 79    if isinstance(value, (bool, int, float)):
 80        return type(value).__name__ + '=' + str(round(float(value), 6))
 81    if isinstance(value, str):
 82        return 'str=' + value[:10]
 83    return type(value).__name__
 84
 85#++++++++++++++++++++++++++++++++++++++++++++++++++
 86#items: three paths per parameter
 87#  set  - mbs.Set<Kind>Parameter on an item created from the default dict
 88#  dict - mbs.Add<Kind> with the default dict, this parameter replaced (no Python checks)
 89#  class- the itemInterface class with this parameter, then mbs.Add<Kind>
 90#the default dict is built from the class signature, because some classes reject their own
 91#defaults (e.g. MarkerNodeCoordinate: coordinate=InvalidIndex() fails CheckForValidUInt)
 92records = {}
 93
 94def SetRangeChecks(active):
 95    """exudyn.special.exceptions.parameterRangeChecks (#2464)"""
 96    if hasattr(exu.special.exceptions, 'parameterRangeChecks'):
 97        exu.special.exceptions.parameterRangeChecks = active
 98
 99def Record(key, row):
100    records.setdefault(' '.join(row), []).append(key)
101
102def DefaultDict(itemClass, typeKey, itemType):
103    d = {typeKey: itemType}
104    for name, parameter in inspect.signature(itemClass).parameters.items():
105        if name == 'visualization':
106            for vName, value in parameter.default.items():
107                d['V' + vName] = value
108        elif name != 'name':
109            d[name] = parameter.default
110    return d
111
112for className, itemClass in inspect.getmembers(itemInterface, inspect.isclass):
113    kind = [k for k in itemKinds if className.startswith(k)]
114    if not kind or className.startswith('V'):
115        continue
116    if getattr(itemClass, '_is_protocol', False):
117        continue    #the Protocol of a user function is a type, not an item: ObjectGround... (#2664)
118    kind = kind[0]
119    typeKey = kind.lower() + 'Type'
120    itemType = className[len(kind):]
121    Add = lambda mbs, item: getattr(mbs, 'Add' + kind)(item)
122    defaults = DefaultDict(itemClass, typeKey, itemType)
123
124    SC = exu.SystemContainer()
125    mbs = SC.AddSystem()
126    Record(className + ' defaults', [Outcome(lambda: Add(mbs, dict(defaults))),
127                                     Outcome(lambda: Add(SC.AddSystem(), itemClass()))])
128    #the base item is created with range checks switched off, because some defaults violate their
129    #range (e.g. coordinate=InvalidIndex() for a UInt, #2426); a default that is rejected when written
130    #back with checks on is replaced by 1 in the dict and class paths, so those paths probe one
131    #parameter at a time instead of failing on another one.
132    mbs = SC.AddSystem()
133    with contextlib.redirect_stdout(io.StringIO()):
134        try:
135            SetRangeChecks(False)
136            index = Add(mbs, dict(defaults))
137        except Exception:
138            continue
139        finally:
140            SetRangeChecks(True)
141    members = [m for m in defaults if m != typeKey]
142    fixes = {}
143    for member in members:
144        #every parameter, the deprecated names included: their warnings are expected and kept off the console (#2873)
145        with contextlib.redirect_stdout(io.StringIO()), warnings.catch_warnings():
146            warnings.simplefilter('ignore', DeprecationWarning)
147            try:
148                getattr(mbs, 'Set' + kind + 'Parameter')(index, member, defaults[member])
149            except Exception:
150                try:
151                    getattr(mbs, 'Set' + kind + 'Parameter')(index, member, 1)
152                    fixes[member] = 1
153                except Exception:
154                    pass
155    defaults.update(fixes)
156    classFixes = {m: v for m, v in fixes.items() if not m.startswith('V')}
157
158    for member in members:
159        GetParameter = lambda mbs, i: getattr(mbs, 'Get' + kind + 'Parameter')(i, member)
160        #left out of the dict: the default is kept ('str=default'), a must-be-given parameter raises
161        def RunOmit():
162            mbs2 = SC.AddSystem()
163            value = Describe(GetParameter(mbs2, Add(mbs2, {m: v for m, v in defaults.items() if m != member})))
164            mbs3 = SC.AddSystem()
165            return 'default' if value == Describe(GetParameter(mbs3, Add(mbs3, dict(defaults)))) else value
166        Record(className + '.' + member + ' omit', [Outcome(RunOmit)])
167        for path in ['set', 'dict', 'class']:
168            row = []
169            for probeName, probe in probes:
170                if path == 'set':
171                    def Run():
172                        getattr(mbs, 'Set' + kind + 'Parameter')(index, member, probe)
173                        return GetParameter(mbs, index)
174                elif path == 'dict':
175                    def Run():
176                        mbs2 = SC.AddSystem()
177                        return GetParameter(mbs2, Add(mbs2, dict(defaults, **{member: probe})))
178                else:
179                    def Run():
180                        if member[0] == 'V' and member[1:] in inspect.signature(itemClass).parameters['visualization'].default:
181                            visualization = dict(inspect.signature(itemClass).parameters['visualization'].default)
182                            visualization[member[1:]] = probe
183                            item = itemClass(visualization=visualization, **classFixes)
184                        else:
185                            item = itemClass(**dict(classFixes, **{member: probe}))
186                        mbs2 = SC.AddSystem()
187                        return GetParameter(mbs2, Add(mbs2, item))
188                row.append(Outcome(Run))
189            Record(className + '.' + member + ' ' + path, row)
190    SC.Reset()
191
192#++++++++++++++++++++++++++++++++++++++++++++++++++
193#structures: attribute set on every leaf of simulationSettings and visualizationSettings
194#the leaves are taken from GetDictionary(), which also skips the deprecated members
195def Leaves(obj, dictionary, path):
196    for name, value in dictionary.items():
197        if isinstance(value, dict):
198            yield from Leaves(getattr(obj, name), value, path + '.' + name)
199        else:
200            yield obj, name, path + '.' + name, getattr(obj, name)
201
202SC = exu.SystemContainer()
203for rootName, root in [('SimulationSettings', exu.SimulationSettings()), ('VisualizationSettings', SC.visualizationSettings)]:
204    saved = root.GetDictionary()
205    for owner, name, path, original in list(Leaves(root, saved, rootName)):
206        keys = path.split('.')[1:]
207        for access in ['attribute', 'dict']:
208            row = []
209            for probeName, probe in probes:
210                def Run():
211                    if access == 'attribute':
212                        setattr(owner, name, probe)
213                    else: #SetDictionary with this leaf replaced
214                        d = root.GetDictionary()
215                        sub = d
216                        for key in keys[:-1]:
217                            sub = sub[key]
218                        sub[keys[-1]] = probe
219                        root.SetDictionary(d)
220                    return getattr(owner, name)
221                row.append(Outcome(Run))
222                with contextlib.redirect_stdout(io.StringIO()):
223                    root.SetDictionary(saved) #restore everything, whatever the probe left behind
224            Record(path + ' ' + access, row)
225
226#++++++++++++++++++++++++++++++++++++++++++++++++++
227inspect.getframeinfo = inspectGetFrameInfo
228exu.config.printToConsole = printToConsole
229exu.special.exceptions.writeErrorsToLogFile = writeErrorsToLogFile
230
231#++++++++++++++++++++++++++++++++++++++++++++++++++
232#text: one block per distinct outcome row, followed by the parameters that share it, one line
233#per owner: '  ObjectMassPoint: mass(dict,set) nodeNumber(class)'
234def SplitKey(key): #'ObjectMassPoint.mass set' -> owner, member, path
235    name, path = key.split(' ')
236    if '.' not in name: #'ObjectMassPoint defaults'
237        return name, '', path
238    owner, member = name.rsplit('.', 1)
239    return owner, member, path
240
241lines = ['#probes: ' + ' '.join(p[0] for p in probes)]
242for row in sorted(records):
243    lines.append('')
244    lines.append('outcome: ' + row)
245    owners = {}
246    for key in records[row]:
247        owner, member, path = SplitKey(key)
248        owners.setdefault(owner, {}).setdefault(member, []).append(path)
249    for owner in sorted(owners):
250        lines.append('  ' + owner + ': ' + ' '.join(member + '(' + ','.join(sorted(paths)) + ')'
251                                                     for member, paths in sorted(owners[owner].items())))
252text = '\n'.join(lines) + '\n'
253
254numberOfDifferences = 0
255if recordReference:
256    with open(referenceFile, 'w', encoding='utf8') as f:
257        f.write(text)
258    exu.Print('parameterConversionTest: reference written, ' + str(len(records)) + ' outcome rows')
259else:
260    with open(referenceFile, encoding='utf8') as f:
261        reference = f.read()
262    def Parse(t):
263        outcome = {}
264        row = ''
265        for line in t.splitlines():
266            if line.startswith('outcome: '):
267                row = line[len('outcome: '):]
268            elif line.startswith('  '):
269                owner, members = line.strip().split(': ', 1)
270                for entry in members.split(' '):
271                    member, paths = entry[:-1].split('(')
272                    for path in paths.split(','):
273                        outcome[(owner + '.' + member if member else owner) + ' ' + path] = row
274        return outcome
275    new, old = Parse(text), Parse(reference)
276    for key in sorted(set(new) | set(old)):
277        if new.get(key) != old.get(key):
278            numberOfDifferences += 1
279            if numberOfDifferences <= 20:
280                exu.Print('parameterConversionTest: ' + key + '\n  reference: ' + str(old.get(key)) + '\n  now:       ' + str(new.get(key)))
281    exu.Print('parameterConversionTest: ' + str(len(new)) + ' parameter paths, ' + str(numberOfDifferences) + ' differences')
282
283exu.sys['testResult'] = numberOfDifferences