Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/Richer cmd output for listing commands #816

Merged
merged 78 commits into from
Jan 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
2ef7767
Changed signal termination
germa89 Dec 20, 2021
5f84f1c
Adding CommandOutput class.
germa89 Dec 20, 2021
9dc57fc
Substituting the output in mapdl_grpc for custom class.
germa89 Dec 20, 2021
2c508bb
Merge branch 'fix/os-error-when-using-os.kill-in-unit-testing' into f…
germa89 Dec 20, 2021
28a838f
Making sure all the methods call the parent class (str)
germa89 Dec 20, 2021
18525a7
Testing simplied version
germa89 Dec 20, 2021
9c2b52f
Added unit tests
germa89 Dec 21, 2021
915444c
Added unit tests
germa89 Dec 21, 2021
652ca3d
Fixing the style
germa89 Dec 21, 2021
c8ec465
Merge branch 'feat/richer-command-output' of https://github.com/pyans…
germa89 Dec 21, 2021
9cbda1e
Fixing grammar.
germa89 Dec 21, 2021
52c3e4a
Fixing grammar.
germa89 Dec 21, 2021
1ff363e
Merge branch 'feat/richer-command-output' of https://github.com/pyans…
germa89 Dec 21, 2021
12b143f
Merge branch 'feat/richer-command-output' of https://github.com/pyans…
germa89 Dec 21, 2021
8bc2768
Add test_class unit
germa89 Dec 21, 2021
64dbd92
Using first implementation because the second fail because of the mod…
germa89 Dec 21, 2021
c110b88
Changing implementation to not overwrite __class__.
germa89 Dec 21, 2021
9fdc7f7
Fixing sphinx building by rewriting __class__ method to not be overwr…
germa89 Dec 21, 2021
53aab29
Style check.
germa89 Dec 21, 2021
d98bd79
changing the API, cmd=command, and command= full command (cmd + args)
germa89 Dec 28, 2021
edde78f
Adding CommandOutputDataframe class.
germa89 Dec 28, 2021
3f17960
Small changes.
germa89 Dec 28, 2021
71665ec
Big restructure.
germa89 Dec 29, 2021
bcfd4b8
Added check if output is modified by /verify
germa89 Dec 29, 2021
bdb0ae3
Added docstrings to classes.
germa89 Dec 29, 2021
d7a15d2
Moved fixtures to main conf file
germa89 Dec 29, 2021
2bbe92d
Changed class name, added supported commands.
germa89 Dec 29, 2021
3aece4b
Added test units.
germa89 Dec 29, 2021
252660a
Added more unit test.
germa89 Dec 29, 2021
9449878
Fixing style.
germa89 Dec 29, 2021
0d4156e
small changes.
germa89 Dec 29, 2021
3210e24
small change.
germa89 Dec 29, 2021
a3e382b
incorrect package name.
germa89 Dec 30, 2021
4471208
UserString Implementation
germa89 Jan 3, 2022
ebcf76d
Simplification of unit test.
germa89 Jan 3, 2022
78c5fba
Simplification of unit test.
germa89 Jan 3, 2022
f9506b5
Merge
germa89 Jan 4, 2022
8cf5632
Using str as base class.
germa89 Jan 4, 2022
18c7364
Merge branch 'feat/richer-command-output' into feat/richer_cmd_output…
germa89 Jan 4, 2022
9c50fe9
removed unused import.
germa89 Jan 4, 2022
4cddd9c
Merge branch 'main' into feat/richer-command-output
akaszynski Jan 5, 2022
bf428a7
Generalization of 'CommandListing' based on function 'paprnt.F'.
germa89 Jan 5, 2022
18e76dd
Apply suggestions from code review
germa89 Jan 5, 2022
abf5b76
Merge branch 'feat/richer-command-output' into feat/richer_cmd_output…
germa89 Jan 5, 2022
4fa9a5e
Cleanning some commented code.
germa89 Jan 10, 2022
c646be0
Added automatical wrapper for listing functions.
germa89 Jan 10, 2022
b2760a7
Removed automatical wrapper.
germa89 Jan 10, 2022
ae7e09d
Fixing style.
germa89 Jan 10, 2022
db2b507
changed method name to `to_list`
germa89 Jan 10, 2022
546f96a
Fixing style
germa89 Jan 10, 2022
28353b6
Added unit test.
germa89 Jan 10, 2022
e646b2c
Fixing grammar.
germa89 Jan 10, 2022
08e21fa
Fixing conftest?
germa89 Jan 10, 2022
b84330d
Improving unit test to avoid empty object false assertion.
germa89 Jan 10, 2022
c5ccd61
Fixing unit test
germa89 Jan 10, 2022
9dd3355
replacing solve for one which does not change format.
germa89 Jan 10, 2022
5ed6011
checking format
germa89 Jan 10, 2022
2a65788
Fixing unit test mess
germa89 Jan 10, 2022
7257a78
testing disabling new test.
germa89 Jan 10, 2022
52595ed
removing unused imports
germa89 Jan 10, 2022
cb1bc8a
removing unused imports
germa89 Jan 10, 2022
13fe3ae
test
germa89 Jan 10, 2022
29de4b4
fixing wrong check against empty array and df
germa89 Jan 10, 2022
2a620b3
Removed unused import
germa89 Jan 10, 2022
03aaa10
Merge branch 'main' into feat/richer_cmd_output-dataframes-and-BC-list
akaszynski Jan 10, 2022
7b8b078
Fixing style.
germa89 Jan 10, 2022
1480eb4
Update ansys/mapdl/core/commands.py
germa89 Jan 10, 2022
47b3206
Added output checker
germa89 Jan 10, 2022
dd94b8b
Adding suggestions.
germa89 Jan 10, 2022
ebdc5bb
Fixing wrong wrapper.
germa89 Jan 10, 2022
31cb8a6
Improving test.
germa89 Jan 10, 2022
719e463
Moving the wrapping to main Mapdl, being common to gpc, console and c…
germa89 Jan 11, 2022
0b9ba45
Added docstring injector.
germa89 Jan 11, 2022
b3f7dda
Apply suggestions from code review
akaszynski Jan 11, 2022
5fce87a
Apply suggestions from code review
akaszynski Jan 11, 2022
995b4cd
Replacing the check of the pandas package.
germa89 Jan 11, 2022
708cb5c
importing pandas lazily
germa89 Jan 11, 2022
6ad1bba
removed trailing space.
germa89 Jan 11, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 320 additions & 1 deletion ansys/mapdl/core/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,160 @@
inq_func
)

import re
import numpy as np

MSG_NOT_PANDAS = """'Pandas' is not installed or could not be found.
Hence this command is not applicable.

You can install it using:
pip install pandas
"""

# Identify where the data start in the output
GROUP_DATA_START = ['NODE', 'ELEM']

# Allowed commands to get output as array or dataframe.
# In theory, these commands should follow the same format.
# Some of them are not documented (already deprecated?)
# So they won't be wrapped.
CMD_LISTING = [
'NLIN', # not documented
'PRCI',
'PRDI', # Not documented.
'PREF', # Not documented.
'PREN',
'PRER',
'PRES',
'PRET',
'PRGS', # Not documented.
'PRIN',
'PRIT',
'PRJS',
'PRNL',
'PRNM', # Not documented.
'PRNS',
'PROR',
'PRPA',
'PRRF',
'PRRS',
'PRSE',
'PRSS', # Not documented.
'PRST', # Not documented.
'PRVE',
'PRXF', # Not documented.
'STAT',
'SWLI'
]

# Adding empty lines to match current format.
docstring_injection = """
Returns
-------

str
Str object with the command console output.

This object also has the extra methods:

* ``str.to_list()``

* ``str.to_array()`` (Only on listing commands)

* ``str.to_dataframe()`` (Only if Pandas is installed)

For more information visit `PyMAPDL Post-Processing <https://mapdldocs.pyansys.com/user_guide/post.html>`_.

"""


def get_indentation(indentation_regx, docstring):
return re.findall(indentation_regx, docstring, flags=re.DOTALL|re.IGNORECASE)[0][0]


def indent_text(indentation, docstring_injection):
return '\n'.join([indentation + each for each in docstring_injection.splitlines() if each.strip()])
# return '\n'.join([indentation + each if each.strip() else '' for each in docstring_injection.splitlines()])


def get_docstring_indentation(docstring):
indentation_regx = r'\n(\s*)\n'
return get_indentation(indentation_regx, docstring)


def get_sections(docstring):
return [each.strip().lower() for each in re.findall(r'\n\s*(\S*)\n\s*-+\n', docstring)]


def get_section_indentation(section_name, docstring):
sections = get_sections(docstring)
if section_name.lower().strip() not in sections:
raise ValueError(f"This section '{section_name.lower().strip()}' does not exist in the docstring.")
section_match = section_name + r'\n\s*-*'
indent_match = r'\n(\s*)(\S)'
indentation_regx = section_match + indent_match
return get_indentation(indentation_regx, docstring)


def inject_before(section, indentation, indented_doc_inject, docstring):
return re.sub(section + r'\n\s*-*', f"{indented_doc_inject.strip()}\n\n{indentation}\g<0>", docstring, flags=re.IGNORECASE)


def inject_after_return_section(indented_doc_inject, docstring):
return re.sub('Returns' + r'\n\s*-*', f"{indented_doc_inject.strip()}\n", docstring, flags=re.IGNORECASE)


def inject_docs(docstring):
"""Inject a string in a docstring"""
return_header = r'Returns\n\s*-*'
if re.search(return_header, docstring):
# There is a return block already, probably it should not.
indentation = get_section_indentation('Returns', docstring)
indented_doc_inject = indent_text(indentation, docstring_injection)
return inject_after_return_section(indented_doc_inject, docstring)
else:
# There is not returns header
# find sections
sections = get_sections(docstring)

if 'parameters' in sections:
ind = sections.index('parameters')
if ind == len(sections) - 1:
# The parameters is the last bit. Just append it.
indentation = get_section_indentation('Parameters', docstring)
indented_doc_inject = indent_text(indentation, docstring_injection)
return docstring + '\n' + indented_doc_inject
else:
# inject it right before the section after 'parameter'
sect_after_parameter = sections[ind + 1]
indentation = get_section_indentation(sect_after_parameter, docstring)
indented_doc_inject = indent_text(indentation, docstring_injection)
return inject_before(sect_after_parameter, indentation, indented_doc_inject, docstring)

elif 'notes' in sections:
indentation = get_section_indentation('Notes', docstring)
indented_doc_inject = indent_text(indentation, docstring_injection)
return inject_before('Notes', indentation, indented_doc_inject, docstring)

else:
indentation = get_docstring_indentation(docstring)
indented_doc_inject = indent_text(indentation, docstring_injection)
return docstring + '\n' + indented_doc_inject


def check_valid_output(func):
"""Wrapper that check if output can be wrapped by pandas, if not, it will raise an exception."""

def func_wrapper(self, *args, **kwargs):
output = self.__str__()
if '*** WARNING ***' in output or '*** ERROR ***' in output: # Error should be caught in mapdl.run.
err_type = re.findall('\*\*\* (.*) \*\*\*', output)[0]
msg = f'Unable to parse because of next {err_type.title()}' + '\n'.join(output.splitlines()[-2:])
raise ValueError(msg)
else:
return func(self, *args, **kwargs)
return func_wrapper


class PreprocessorCommands(
preproc.database.Database,
Expand Down Expand Up @@ -219,7 +373,13 @@ class Commands(


class CommandOutput(str):
"""Customized command output."""
"""Custom string subclass for handling the commands output.

This class add two method to track the cmd which generated this output.
* ``cmd`` - The MAPDL command which generated the output.
* ``command`` - The full command line (with arguments) which generated the output.

"""

## References:
# - https://stackoverflow.com/questions/7255655/how-to-subclass-str-in-python
Expand Down Expand Up @@ -249,3 +409,162 @@ def command(self):
def command(self):
"""Not allowed to change the value of ``command``."""
pass


class CommandListingOutput(CommandOutput):
"""Allow the conversion of command output to native Python types.

Custom class for handling the commands whose output is sensible to be converted to
a list of lists, a Numpy array or a Pandas DataFrame.
"""

def _is_data_start(self, line, magicword=None):
"""Check if line is the start of a data group."""
if not magicword:
magicword = GROUP_DATA_START

# Checking if we are supplying a custom start function.
if self.custom_data_start(line) is not None:
return self.custom_data_start(line)

if line.split():
if line.split()[0] in magicword or self.custom_data_start(line):
return True
return False

def _is_data_end(self, line):
"""Check if line is the end of a data group."""

# Checking if we are supplying a custom start function.
if self.custom_data_end(line) is not None:
return self.custom_data_end(line)
else:
return self._is_empty(line)

def custom_data_start(self, line):
"""Custom data start line check function.

This function is left empty so it can be overwritten by the user.

If modified, it should return ``True`` when the line is the start
of a data group, otherwise it should return ``False``.
"""
return None

def custom_data_end(self, line):
"""Custom data end line check function.

This function is left empty so it can be overwritten by the user.

If modified, it should return ``True`` when the line is the end
of a data group, otherwise it should return ``False``.
"""
return None

@staticmethod
def _is_empty_line(line):
return bool(line.split())

def _format(self):
"""Perform some formatting (replacing mainly) in the raw text."""
return re.sub(r'[^E](-)', ' -', self.__str__())

def _get_body(self, trail_header=None):
"""Get command body text.

It removes the maximum absolute values tail part and makes sure there is separation between columns.
"""
# Doing some formatting of the string
body = self._format().splitlines()

if not trail_header:
trail_header = ['MAXIMUM ABSOLUTE VALUES', 'TOTAL VALUES']

if not isinstance(trail_header, list):
trail_header = list(trail_header)

# Removing parts matching trail_header
for each_trail_header in trail_header:
if each_trail_header in self.__str__():
# starting to check from the bottom.
for i in range(len(body) - 1, -1, -1):
if each_trail_header in body[i]:
break
body = body[:i]
return body

def _get_data_group_indexes(self, body, magicword=None):
"""Return the indexes of the start and end of the data groups."""

if '*****ANSYS VERIFICATION RUN ONLY*****' in str(self):
shift = 2
else:
shift = 0

# Getting pairs of starting end
start_idxs = [ind for ind, each in enumerate(body) if self._is_data_start(each, magicword=magicword)]
end_idxs = [ind - shift for ind, each in enumerate(body) if self._is_empty_line(each)]

indexes = [*start_idxs, *end_idxs]
indexes.sort()

ends = [indexes[indexes.index(each) + 1] for each in start_idxs[:-1]]
ends.append(len(body))

return zip(start_idxs, ends)

def _get_data_groups(self, magicword=None, trail_header=None):
"""Get raw data groups"""
body = self._get_body(trail_header=trail_header)

data = []
for start, end in self._get_data_group_indexes(body, magicword=magicword):
data.extend(body[start+1:end])

# removing empty lines
return [each for each in data if each]

def get_columns(self):
"""Get the column names for the dataframe.

Returns
-------
List of strings

"""
body = self._get_body()
pairs = list(self._get_data_group_indexes(body))
return body[pairs[0][0]].split()

@check_valid_output
def to_list(self):
"""Export the command output a list or list of lists.

Returns
-------
List of strings
"""
data = self._get_data_groups()
return [each.split() for each in data]

def to_array(self):
"""Export the command output as a numpy array.

Returns
-------
Numpy array
"""
return np.array(self.to_list(), dtype=float)

def to_dataframe(self):
"""Export the command output as a Pandas DataFrame.

Returns
-------
Pandas Dataframe
"""
try:
import pandas as pd
except ModuleNotFoundError:
raise ModuleNotFoundError(MSG_NOT_PANDAS)
return pd.DataFrame(data=self.to_array(), columns=self.get_columns())
19 changes: 18 additions & 1 deletion ansys/mapdl/core/mapdl.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from ansys.mapdl.core.errors import MapdlRuntimeError, MapdlInvalidRoutineError
from ansys.mapdl.core.plotting import general_plotter
from ansys.mapdl.core.post import PostProcessing
from ansys.mapdl.core.commands import Commands
from ansys.mapdl.core.commands import Commands, CommandListingOutput, CMD_LISTING, inject_docs
from ansys.mapdl.core.inline_functions import Query
from ansys.mapdl.core import LOG as logger
from ansys.mapdl.reader.rst import Result
Expand Down Expand Up @@ -165,6 +165,23 @@ def __init__(self, loglevel='DEBUG', use_vtk=True, log_apdl=None,

self._post = PostProcessing(self)

self._wrap_listing_functions()

def _wrap_listing_functions(self):
# Wrapping LISTING FUNCTIONS.
def wrap_listing_function(func):
# Injecting doc string modification
func.__func__.__doc__ = inject_docs(func.__func__.__doc__)
@wraps(func)
def inner_wrapper(*args, **kwargs):
return CommandListingOutput(func(*args, **kwargs))
return inner_wrapper

for name in dir(self):
if name[0:4].upper() in CMD_LISTING:
func = self.__getattribute__(name)
setattr(self, name, wrap_listing_function(func))

@property
def _name(self): # pragma: no cover
"""Implemented by child class"""
Expand Down
2 changes: 1 addition & 1 deletion ansys/mapdl/core/mapdl_grpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2004,4 +2004,4 @@ def erinqr(self, key, **kwargs):
def wrinqr(self, key, **kwargs):
"""Wrap the ``wrinqr`` method to take advantage of the gRPC methods."""
super().wrinqr(key, pname=TMP_VAR, mute=True, **kwargs)
return self.scalar_param(TMP_VAR)
return self.scalar_param(TMP_VAR)
1 change: 0 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
)
from common import get_details_of_nodes, get_details_of_elements, Node, Element


# Necessary for CI plotting
pyvista.OFF_SCREEN = True

Expand Down
Loading