Closed john-hen closed 2 years ago
As mentioned in #16, the next feature release, version 0.9, will be out soon, pending more tests and a few updates to the documentation.
As of 0.9, the preferred way to start a local Comsol session is calling mph.start()
. This function is new. Previously, we had to instantiate mph.Client
.
mph.start()
creates a stand-alone client on Windows, and a local client and server on Linux and macOS. The latter is due to the platform limitations explained in #8. The former is for backward compatibility and for performance reasons too. The stand-alone client spins up in about half the time compared to a server process. And it's probably the more robust setup as well, as it avoids the fickle communication over the local, and dynamically assigned, TCP socket.
It has always been my goal for release 0.9 to have support for all three platforms. Release 1.0 should follow when operation is proven to be stable and to work in multi-processing mode. That is, when it's clear that separate Python subprocesses can be reliably run from one parent process, so that we can have multiple workers contributing to a single job queue, say for (programmable) parameter sweeps, or even optimization algorithms that can be parallelized, such as the "genetic algorithm".
I also noticed that I forgot to publish the last bug-fix release 0.8.2 here on GitHub. I'll take care of that at the same time. The release date will be wrong, but the release page here is mainly there for keeping the change-log. Actual release dates are those displayed on PyPI.
I just uploaded a demo script that has a pool of workers solve a parameter sweep in parallel. It uses the multiprocessing
module from the standard library.
As half-expected, this works without a hitch on Windows, where we run the session with a stand-alone client. But doesn't work on Linux, where we use client-server mode. The problem is the port assignment, as the different server processes get in each other's way as they start up, all trying to grab the same TCP port, 2036. It helps to introduce artificial delays, but that's not only an ugly hack, it would also be a performance hindrance on machines with many cores.
John, this sounds really cool. Worked out of the box on Catalina and I excited to test all the features. Congrats on the initiative!
Thanks, Gustavo, glad to hear! Feel free to post feedback and questions here as you go along.
Status update...
The multiprocessing
demo worker_pool.py
works now and is also documented. We have to manually assign server ports via mph.start()
when using client-server mode (Linux and macOS) to create multiple processes at once. Version 1.0 will be released once the (basic) support for feature creation and property changes, discussed in #25, has been implemented.
I think I do have two more things to throw in the ring:
1) Add a connect()
method to client. Two reasons: First, theres disconnect()
in the API, thus connect makes sense. Secondly, a practical reason would be loading a model locally, performing some tasks, namely IO which is faster local, and then moving to a remote to perform calculation without exiting the interpreter.
If this is an option we should check if we need to clear()
in disconnect()
or if this is done by default. Or if the models can even be transfered to a new server.
2) The save
mehod could allow to specify the ending (optional argument) to allow file conversion to MATLAB, java or vba as supported by COMSOL.
First of all, sure, there can be more things. Release 1.0 would take us out of beta and into production/stable territory, so I'm all for waiting a bit longer before pulling the trigger. Could be that our tests will reveal more bugs to fix, which would be a good thing.
Yes, connect()
makes sense because there's already disconnect()
. But connect()
is implicitly called by __init()__
if port
is passed, so this would be more like "reconnect". I'm not sure this is feasible, especially without breaking the internal logic, where we also support stand-alone clients. And I've never encountered use cases where time spent on I/O operations comes anywhere close to simulation times. (I'm talking an hour here for solving, or hours, plural, maybe even days.) Also, I have no way of testing this, as I don't own a networking license. But if you do, and you can make this work seamlessly, go ahead.
That's perfectly reasonable. Maybe we just don't enforce the file ending at all. I can see a use case here. If Comsol's save()
method supports this behavior (I've never bothered to check), then we can just pass on the goodies.
Regarding
2) It needed a little workaround since the signature of java.save
changes slightly. Please take a look at https://github.com/John-Hennig/MPh/compare/master...max3-2:m32-modelSaveTypes
I looked at this the other day and wasn't sure if we want to have this new argument type
, or if it wouldn't be more "pythonic" to case-select depending on the file's .suffix
. But then couldn't really decide if I like one way or the other. But you can create a PR for this (too), I will merge it once I've made up my mind.
I just released 0.9.1 with the latest changes that were in master
. These are basically all bug fixes, therefore the modest version bump. Will get to the new features in the open PRs next.
FYI, I've removed the sandbox
folder from master
and put it in a separate branch named sandbox
. Something to keep in mind when rebasing on master
.
I've renamed the master
branch to main
, see github/renaming.
We're getting closer to release 1.0. I'm planning to then get a DOI from Zenodo in case anyone ever wants to cite MPh in a paper. So testing, more than anything else, is the order of business until that release.
By the way, the docs at mph.readthedocs.io always point to the stable release, which is 0.9.1 at this point. But the latest version, i.e. the development version in main
, is also available there. One just has to open the version pop-up menu at the bottom left. The docs
badge on the GitHub repo's main page links to that latest version directly.
So testing, more than anything else, is the order of business until that release.
I will do just that the next days. Got some COMSOL on my table anyway.
get a DOI from Zenode
Please do that. I work in Academia and many around me use COMSOL and are not happy with the scripting, so there will some users I guess. I will obviously spread the 1.0 word
If you find bugs with the latest additions, you can just report them here in chat or in the other issue. No need to track them separately if they don't concern a past release.
One thing I already know: The wheel doesn't build at the moment. That's because I put an α in the version number, which flit
doesn't like. But this will sort itself out when the release is final.
I work in Academia and many around me use COMSOL and are not happy with the scripting
Not just in academia. I think the first bullet point in the release notes will be some variation of: "We now offer you the best API Comsol has ever seen! 🎉"
Hello,
First, thanks for making this great API. I was very excited and this should help with some coupled modelling I hope to do. I have been struggling with evaluating my results however. I am running a PEMFC simulation very similar to this one
One of the key results, current, is acquired via a volume integration however, as far as I know I can only extract the local current per volume relative to x, y, and z coordinates. I think I can find the integrated value by summing the local current per volume multiplied by the element the current per volume is assigned to, however it is quite difficulty to pin-point exactly which elements correspond to which current. This is particularly difficult since I think the points align with element boundaries rather than say the centre of the elements. If I go with this method is there a way to extract the mesh information (volume of each element, indices of element boundaries etc.?
The best thing for me would be if there is a way to access table values created within COMSOL. Is that possible through this API? Otherwise do you have any other method I could try to get the value I'm after? Please let me know if you need any more info. Any insight into this would be greatly appreciated!
Cheers,
Eric
Eric,
let me try to help you here: In general, the evaluate functionality is not that mature. There are few dataset types that are supported - I guess yours is not as we focused on editing and adding model features as you can see from the issues. Currently, I see a few possibilites:
model.java
and then go on from there. I encourage you to try the new main where we have nodes implemented and you can do Node(‚path/in/comsol/tree‘).java
to quickly get the java object. In your case this would point to your table you have set up. Then take a look at the COMSOL API Reference what you can to with tables, Chapter 2 model.results(), and then look for model.results().table()
for methods. The latter will be your Node.java
.model.java
object to reproduce arbitrary functionality.As soon as nodes are working safely (Release 1.0.
presumably) working with tables is easier, see bullet 2. This is why I encourage you to try it with the current main.
At last - if its not confidential - feel free to upload a stripped model and let me know what you need in more detail
Hi Eric, I'm glad you like the API. It will only get better in the upcoming release.
As Max explained, MPh does not replicate the entire Comsol API. Tables, for example, are not covered by MPh, and neither are certain results types, such as "volume integration" (accessible under "Derived Values" in the GUI). But if push comes to shove, you can always access the Comsol API directly, from Python, see section "Creating models" in the documentation.
I don't think that's necessary in this case. First of all, you should certainly let Comsol do the volume integration. Comsol knows its finite elements better than any of us, no need to try and recreate that in Python. What I would do here, I would define an integration coupling operator (under Definitions→Variables near the top of the model tree) and then use that in a global evaluation in post-processing. Global evaluations are supported by MPh, you can just pass the same expression to model.evaluate()
that you would enter in the GUI.
Generally, this is the recommended way to automate post-processing: You define what you need in the model itself and then just trigger the evaluation or export via MPh.
HI Max and John,
Thank you both for the quick replies! As I was figuring out how to do John's solution, I realized I already had global evaluations in the form of a domain probe and that's how I was getting my results in COMSOL in the first place. I just did not realize the results of the evaluations had expressions I could call with model.evaluation(). Thanks again! I look forward to new releases and hope to cite this API in a paper at some point!
Cheers,
Eric
Also with the 1.0 release, I'm planning to move the repository to the GitHub "organization" MPh-py, which I've just created for that very purpose. Mostly to have shorter/nicer URLs when linking to the repo. I don't think we need the organization features. But lots of other projects use such a structure, JPype for example, so I figure this can't be wrong.
I hope all existing URLs will be redirected, as the GitHub documentation promises, so that old documentation builds of MPh don't suddenly link to dead targets when referring to the GitHub repo. Forks will automatically point to the new location and issues will be transferred. Only local clones may need to be reconfigured to push to the new upstream
repo.
I will hold off on that until the release is final, because the meta information on PyPI would also need updating, and that can only happen when a release is published.
Hi all,
I thought I would share a new function that I adapted from the evaluate code:
` def interp(self, expression, coord=None, unit=None, dataset=None, inner=None, outer=None):
# Get dataset and solution (Java) objects.
dataset = self._dataset(dataset)
logger.info(f'Evaluating {expression} on "{dataset.name()}" dataset.')
solution = self._solution(dataset.name())
# Make sure solution has actually been computed.
if solution.isEmpty():
error = 'The solution has not been computed.'
logger.critical(error)
raise RuntimeError(error)
# Validate solution arguments.
if not (inner is None
or (isinstance(inner, str)
and inner in ('first', 'last'))
or (isinstance(inner, list)
and all(isinstance(index, int) for index in inner))
or (isinstance(inner, numpy.ndarray)
and inner.dtype == 'int')):
error = ('Argument "inner", if specified, must be either '
'"first", "last", or a list/array of integers.')
logger.critical(error)
raise ValueError(error)
if not (outer is None
or isinstance(outer, int)
or (hasattr(outer, 'dtype')
and issubclass(outer.dtype.type, numpy.integer)
and not outer.shape)):
error = 'Argument "outer", if specified, must be an integer index.'
logger.critical(error)
raise ValueError(error)
etag = self.java.result().numerical().uniquetag('eval')
eval = self.java.result().numerical().create(etag, 'Interp')
# Set the expression(s) to be evaluated.
eval.set('expr', expression)
# Set the unit(s), if specified.
if unit is not None:
eval.set('unit', unit)
# Select an outer solution, i.e. parameter index, if specified.
if outer is not None:
eval.set('outersolnum', jtypes.JInt(outer))
if coord is not None:
eval.set('coord', coord)
# Retrieve the data.
logger.info('Retrieving data.')
dtype = str(dataset.getType()).lower()
# Retrieve the data.
logger.info('Retrieving data.')
if dtype == 'particle':
results = array(eval.getReal())
if eval.isComplex():
results += 1j * array(eval.getImag())
if isinstance(expression, (tuple, list)):
shape = results.shape[1:]
results = results.reshape(len(expression), -1, *shape)
else:
results = array(eval.getReal)
if eval.isComplex():
results = array(eval.getReal())+1j * array(eval.getImag())
if inner is None:
pass
elif inner == 'first':
results = results[:, 0, :]
elif inner == 'last':
results = results[:, -1, :]
else:
results = results[:, inner, :]
logger.info('Finished retrieving data.')
# Remove the temporary evaluation node we added to the model.
self.java.result().numerical().remove(etag)
# Squeeze out singleton array dimensions.
results = results.squeeze()
# Return array of results.
return results`
With this I was able to pass co-ordinates where I want some field values. An example call might be:
` x = np.linspace((0.12 / xy_points) / 2, 0.12 - (0.12 / xy_points) / 2, xy_points) y = np.linspace((0.12 / xy_points) / 2, 0.12 - (0.12 / xy_points) / 2, xy_points) X,Y = np.meshgrid(x,y) X_flatten = np.reshape(X,(1,no_elements_xy)) Y_flatten = np.reshape(Y,(1,no_elements_xy))
grid = np.concatenate((X_array,Y_array,Z_array))
(E_x) = model.interp('mf.Ex', coord=grid.tolist())
`
This has worked a treat for me.
Thanks for sharing, Joel. It will have to wait until after release 1.0, which is imminent. But we'll get to that eventually. See also feature request #26.
Release 1.0 is out now. :tada:
Thanks a lot for all your help, Max! The new features we discussed in #25 turned out much better than I had ever hoped they would. My original plan was just to be stable on all three platforms by 1.0. But this deserves the version bump even more. Cheers. 🥂
Thank you for the work you put in the project. Let's see how the new features work out and how much is reported back. I will presumably use it myself in the coming weeks, quite a lot. I'm happy to continue supporting in the future (however just now the semester started and I will have substantially less time for coding in the coming weeks)
Hello, Thanks for the great work. Keep it up.
I have updated mph from version 0.8 to 1.0. It seems that if I use the model.parameter function with a complex value, it truncates the imaginary part in the newer version. Can you please advise what to do?
Hi, thanks for bringing this up. I've never tested complex values, but could easily add that to the test suite. Can you please open a new issue and provide a code example? Just the call to model.parameter()
that used to work in 0.8 and now no longer does.
John, I have seen you keep on improving the current setup, nice work. I hope you haven't had to type the tags json by hand 🙃.
Just an idea, I place this here since I am actually not aware if we have discussed this already. I had a co-worker of mine use the package and they suggested that Nodes could be accessible in a dict style notation from the model, eg. model['node/path']
. This in mainly motivated by the idea that not everyone is used to the pathlib
advantages (yet?) but dicts are quite common. There's also contains so this also leads the user to try the dict notation.
Thanks, Max, yeah, I keep noticing things and then can't help fixing them.
No, I didn't type in 4000 tag names by hand, haha. The script that generates the JSON file is in the sandbox
. It "harvests" the tags from Comsol's application library. It still requires a little bit of manual tweaking to resolve conflicts, but that can be done in like five minutes. Which goes to show that Comsol is also not 100% consistent when assigning tag names. This would explain why I couldn't find an API method for that. Though it could also be they added one at some point, but earlier models still have tags that were assigned by hand.
The script can also be run against other models. I used my own models as well. It would then just update the JSON or point out new ambiguities to resolve. However, I only found one tag that was missing, for image exports, so that's one feature that models in the application library don't seem to use.
There may be a few more, but that shouldn't matter much. After all, we do hide the tags almost entirely in the API, and the internal representation shouldn't make a difference. The old method for assigning tags would probably work just fine. But I noticed that physics interfaces in particular also define a "scope". There's a special Java method .scope()
to access that value. For something like an electrostatics interface, we would assign a tag like ele1
, but the reported scope would still be root.comp.es
. I don't know if that's really a problem, I didn't test it actually, but it made me a little uneasy, so that's what I actually wanted to fix. Now feature references via a tag are designed to be consistent with variable references such as es.normE
, just to be on the safe side.
The dictionary notation… Yeah, that actually used to be in main
already, but I took it out (ea5f416e7412455e566a1f1b88124e08d1fd23c1) shortly before releasing 1.0.0. It could go back in. I just didn't want to commit to that. My thinking was that for nodes, not models, the dictionary notation could be used to access properties, and then the notation would be somewhat inconsistent between the two classes. Then again, I'll probably not add property access via dict-style notation, because the built-in node iterator already acts on child nodes, not properties, so that would be inconsistent. (pathlib
uses .iterdir()
for that, so adds a dedicated iterator and does not use the built-in one.) I'll give it another think. The /
notation is documented in two places already, but maybe this could be elaborated more. And we might just throw in the dict-style notation for good measure, but leave it undocumented.
but I took it out
I was sure I had seen it somewhere - that explains.
is documented in two places already …
It is and in a good way. Its more that pathlib
is fairly new and if there‘s something that contains anything in python, dicts come to mind. So I guess its more of a lazyness by the user. Currently I would actually leave it like it is and wait for more usage to decide on what to (if even) sacrifice the dict notation.
On another note: To this point there are no bugs whatsoever in my and some others daily use. I actually tend to use the API more than the GUI if I do have a bit of an idea what to do, its way faster this way 😊!
Great to hear you didn't notice any bugs! Emphasis on "notice" though 😉, I just fixed a few more. It really helped that I finally got pyTest to run on Windows, because now we can have coverage reports. And then it's much easier to add tests here and there for the code lines that are highlighted. I think pyTest did work on Linux before, but I never checked. On Windows it needed some convincing to get along with JPype.
Yeah, I'll probably not put the dict-style notation back in. I realized, it's not actually acting like a dictionary. In that it's read-only, you can't assign to model['node/path']
. And the div notation is more consistent. It actually works with the client as root. We can do client/'capacitor'/'functions'/'step'
for example if a model named "capacitor" was loaded.
Let me comment on this here since the other issue is closed…
Maybe we should have a function mph.running()
I guess this would be a good idea to add convenience. I had something like this in mind but also have the idea of adding a connect
method (since there is disconnect
, and also to „switch“ servers as computation targets) but I have not had the time to work on it sadly.
Going forward, maybe a check like the above can check for JVM and if the client is connected (not needed when stand alone) if we find a check for that. If yes, we could return the client instance, if not go on with connection. This
Client()
working everytimeI never realized people can't comment on closed issues. Well, I guess OP still can. But anyway, here's good too.
So you mean mph.start()
could just return the existing client instance on the second call? Instead of raising NotImplementedError
. I think that's a good idea. It somewhat hides the fact that there's only one client, but on the other hand that's well documented. I wonder what would happen in a threaded application. It's probably gonna crash. But again, I think that's fine. People will figure this out.
I don't think we should have that in Client
though, just in mph.start()
, as that's the convenience function. And also because the active client instance is local to the session
module.
As for connect()
… Like I said before, I'm open to that. I can't really test this, and it won't be in the test suite, but that's okay. We'd just consider that feature experimental. But if we add it, I'd leave that logic out of mph.start()
. If the client is currently disconnected, it would just return the disconnected client. We should separate these concerns.
So you mean mph.start() could just return the existing client instance on the second call? Instead of raising NotImplementedError
Exactly. And if its only in the convenience function it should not interfere with more complex setups. I can take a look how to do that elegantly (and maybe get a look at connect
) around next weekend…
FWIW commenting on closed issues is possible. I just thought it would get lost a bit so I moved it here.
The first thing is actually real easy, I think. It's pretty much if client: return client
in mph.start()
, plus a mention in the doc-string. I can easily just throw that into 1.0.3, which I was going to release tonight, or maybe I'll do it tomorrow. It's just a different way to deal with the error, so has a place in a bug-fix release.
But definitely look into connect()
. That's feature-release material, so perfect for 1.1. For which I don't have a lot of ideas anyway. And there's no rush.
I asked over at jpype for a release of the fix. They seem to be close to pushing a new version. Maybe for 1.1
we can get connect()
and the removal of the shutdown fix?
The shutdown fix could just be 1.0.4 as soon as JPype 1.3 is out. connect()
would be in 1.1. I have a few more ideas for things to add (like Node.selection()
, Node.move()
, Node.copy()
, Model.products()
, Client.products()
), but haven't implemented any of them yet. So I guess we'll do 1.1 whenever we're ready.
Hi all, I thought I would share some code I amended in the model class here if anyone wanted to set the frequency in a frequency domain study:
def solve(self,frequency=numpy.array([]), study=None):
tags = [tag for tag in self.java.study().tags()]
names = self.studies()
index = {name: tag for (tag, name) in zip(tags, names)}
if study is not None:
index = {name: tag for (name, tag) in index.items()
if name == study}
if not index:
error = f'Study "{study}" does not exist.'
logger.critical(error)
raise LookupError(error)
elif not index:
error = 'No study defined in the model tree.'
logger.critical(error)
raise RuntimeError(error)
for (name, tag) in index.items():
logger.info(f'Running study "{name}".')
if frequency.size==0:
self.java.study(tag).run()
else:
self.java.study(tag).feature('freq').set('plist', [str(frequency[i]) for i in range(len(frequency))])
self.java.study(tag).run()
logger.info('Finished solving study.')
Hi Joel. It's better to do this in application code. The purpose of the solve()
method is to run the simulation, not to configure the study steps.
Side note: Your code also changes the call signature. That would break the API. For example, the command model.solve('static')
in the Tutorial will then fail.
Your changes are based on an older version of MPh. Even back then you could do this in application code. But you had to first find out the tag of the study step, which would require a number of direct calls to the Java layer, via model.java
.
As of version 1.0, this can be done more conveniently. For example, if you open the demo model from the tutorial in the Comsol GUI and add a "Frequency Domain" study, it will create a new study named "Study 4" with a study step named "Frequency Domain". I usually change these node names to something more descriptive, but let's say you leave the default ones, save the model, and load it in Python. Then you can set the parameter list for the frequencies with just one line:
(model/'studies'/'Study 4'/'Frequency Domain').property('plist', [1, 5, 10])
To find out node names without the GUI, you can use mph.tree()
as well as the .children()
method from the Node
class. Available settings (as their are called in the GUI) are returned by .properties()
(as they are called in the Comsol API documentation). The latter would include plist
for study steps that sweep a parameter list. While .property()
is essentially a wrapper for the .set()
Java method, but also handles type conversions, for example from NumPy array.
Thanks for the reply John, I will have to install the update. I will take a look at using the other method instead, this was just the quickest way to do this for me, but realise it may not work generally. I did have to change the call, which differs from the tutorial example. Anyway, cheers for the feedback, I will go and have a play around!
Joel.
@John-Hennig I build the connect
prototype in
https://github.com/MPh-py/MPh/compare/main...max3-2:m32-client-connect
Some remarks:
client.py
Tests are not there yet, I can add them if this is ok after review / changes. However testing would mean creating a (second?) server on localhost
to switch back and forth - this is no problem though.
@max3-2 That's great, thanks. But we should discuss some implementation details. Either in the PR or in a separate issue. PR is probably fine, but maybe after you got to adding the test.
You're right, we can actually just test this locally, doesn't have to be a remote connection. The test, for now, can (or even should) just be a very simple script that starts two local servers and then does the switch. Like, demonstrate what you had in mind. That's probably only a few lines of code. You can put it as demo_connect.py
in the tests
folder. We'll integrate it with the test suite when the feature is final.
I build the test and PR the next days then we can continue there :)
On another note: You can take a look at https://github.com/MPh-py/MPh/compare/main...max3-2:m32-classkit-license-support if you have some time. This is in very beta and im running some tests currently but the background is as follows:
-ckl
to choose this license-ckl
with only classkit licenses available yields a licence error on client.load()
as does the way around, e.g. using classkit with a real licenseThis all came up with some students at my institute trying to use MPh
and getting fairly cryptic errors with models / script working perfectly with my and other colleagues licenses. Took me a bit to backtrack the difference in licensing types. As you can see in the fix I proposed, I would use an option to set that - then we only need a few conditional statements to catch and erroneous stand-alone.
Ugh, this sucks. I mean, good catch, and we should totally support that, but it makes everything more complicated.
I faintly remember seeing "classkit" mentioned in the Comsol docs, but I never gave it a second thought. Makes more sense now. It's a special edition for teaching.
According to the Installation Guide, page 70, a Comsol installation on Windows would have a file named comsolclasskit.ini
in the bin\win64
folder. It's not clear from that document if the same is true on Linux and macOS. Can you find out? Because then we might just leverage the presence of that file, instead of the config option, to decide if extra command-line arguments are needed.
Also, for Windows it says the command-line argument is -Dcs.ckl
. This looks like an argument for the Java VM. So maybe stand-alone mode would work if we fixed the call to jpype.startJVM()
.
This is an amazing project and I highly encourage to maintain it!
I may suggest turn on Discussions board for idea, comments, ...
Thanks. Positive feedback like yours certainly helps to keep me motivated, maybe others too, and maintain the project going forward.
I've just turned on Discussions. I don't expect it to be used much, most users are probably not even aware of that GitHub feature. Which is why it was off before. People are more familiar with Issues, and I created this issue here as a catch-all for stuff that isn't a bug and isn't a (well-defined) feature request. But if you see value in the Discussions board, feel free to use it.
Thank you @John-Hennig Discussion will lets you have each idea comments in its own thread and simpler to follow up! Yes, many are not familiar with GitHub features! But here will soon get very messy!
Best wishes
Sorry about the delays getting JPype 1.3 out. There were some issues with changes in the upcoming Python 3.10 version that cause segmentation faults. I believe the situation is resolved and I should be able to finish the release once the changes are approved.
Oh, come on, like you owe us an apology. This project here wouldn't exist without JPype. I am, quite frankly, in awe how complicated it is to get Python and Java to play nice with each other. It's ready when it's ready and we'll wait. Plan is to have one more bug-fix release (1.0.5) in the 1.0 series, which will require JPype >=1.3
for the extra stability, before moving on to adding features for 1.1.
Just dropping by to say thanks for this project. I was using pyautogui to automatically script COMSOL before I found this, and this is so easy to use in comparson. I plan to cite MPh in future work, so please let me know if you get a DOI!
Leave a comment here if you have general feedback, a question to ask, want to float an idea, share a user story, or have anything else to say that may not merit opening a new issue.
Please do not leave a comment here if you are reporting a bug. Then by all means do open a new issue.