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