Синтаксический анализ параметров для потенциальной функции с помощью экономной - PullRequest
1 голос
/ 06 мая 2019

Вопрос изначально задавался по коду обзора . Спрашивать здесь снова по рекомендации


Фон

A forcefield - это набор функций и параметров, который используется для расчета потенциальной энергии сложной системы. У меня есть текстовые файлы, которые содержат данные о параметрах для силового поля. Текстовый файл разбит на несколько разделов, причем каждый раздел имеет один и тот же формат:

  • Заголовок раздела, заключенный в квадратные скобки
  • В следующей строке за словом indices: следует список целых чисел.
  • Затем следует 1 или более строк параметров, связанных с разделом

Вот пример файла для демонстрации формата.

############################################
# Comments begin with '#'
############################################

[lj_pairs] # Section 1
    indices:    0 2
#  ID      eps    sigma
    1       2.344   1.234   5
    2       4.423   5.313   5
    3       1.573   6.321   5
    4       1.921   11.93   5

[bonds]
indices:    0 1
    2   4.234e-03   11.2
    6   -0.134545   5.7

Цель состоит в том, чтобы проанализировать такие файлы и сохранить всю информацию в dict.


В настоящее время у меня есть следующий код для выполнения моей задачи

""" Force-field data reader """

import re
from dataclasses import dataclass, field
from typing import Dict, Iterable, List, TextIO, Tuple, Union, Any


def ff_reader(fname: Union[str, TextIO]) -> Dict[str, "FFSections"]:
    """ Reads data from a force-field file """

    try:
        if _is_string(fname):
            fh = open(fname, mode="r")
            own = True
        else:
            fh = iter(fname)
    except TypeError:
        raise ValueError("fname must be a string or a file handle")

    # All the possible section headers
    keywords = ("lj_pairs", "bonds")  # etc... Long list of possible sections
                                      # Removed for brevity
    re_sections = re.compile(r"^\[(%s)\]$" % "|".join(keywords))
    ff_data = _strip_comments(fh)
    # Empty dict that'll hold all the data.
    final_ff_data = {key: FFSections() for key in keywords}

    # Get first section header
    for line in ff_data:
        match = re.match(re_sections, line)
        if match:
            section = match.group(1)
            in_section_for_first_time = True
            break
        else:
            raise FFReaderError("A valid section header must be the first line in file")
    else:
        raise FFReaderError("No force-field sections exist")

    # Read the rest of the file
    for line in ff_data:

        match = re.match(re_sections, line)

        # If we've encounted a section header the next line must be an index list.
        if in_section_for_first_time:
            if line.split()[0] != "indices:":
                raise FFReaderError(f"Missing index list for section: {section}")
            idx = _validate_indices(line)
            final_ff_data[section].use_idx = idx
            in_section_for_first_time = False
            in_params_for_first_time = True
            continue

        if match and in_params_for_first_time:
            raise FFReaderError(
                f"Section {section} missing parameters"
                + "Sections must contain atleast one type coefficients"
            )

        if match:  # and not in_section_for_first_time and in_params_for_first_time
            section = match.group(1)
            in_section_for_first_time = True
            continue

        params = _validate_params(line)
        final_ff_data[section].coeffs.update([params])
        in_params_for_first_time = False

    # Close the file if we opened it
    if own:
        fh.close()

    for section in final_ff_data.values():
        # coeff must exist if use_idx does
        if section.use_idx is not None:
            assert section.coeffs

    return final_ff_data

def _strip_comments(
    instream: TextIO, comments: Union[str, Iterable[str], None] = "#"
) -> Iterable[str]:
    """ Strip comments from a text IO stream """

    if comments is not None:
        if isinstance(comments, str):
            comments = [comments]
        comments_re = re.compile("|".join(map(re.escape, comments)))
    else:
        comments_re = ".*"
    try:
        for lines in instream.readlines():
            line = re.split(comments_re, lines, 1)[0].strip()
            if line != "":
                yield line
    except AttributeError:
        raise TypeError("instream must be a `TextIO` stream") from None


@dataclass(eq=False)
class FFSections:
    """
    FFSections(coeffs,use_idx)

    Container for forcefield information
    """

    coeffs: Dict[int, List[float]] = field(default_factory=dict)
    use_idx: List[int] = field(default=None)


class FFReaderError(Exception):
    """ Incorrect or badly formatted force-Field data """

    def __init__(self, message: str, badline: Optional[str] = None) -> None:
        if badline:
            message = f"{message}\nError parsing --> ({badline})"
        super().__init__(message)


def _validate_indices(line: str) -> List[int]:
    """
    Check if given line contains only a whitespace separated
    list of integers
    """
    # split on indices: followed by whitescape
    split = line.split("indices:")[1].split()
    # import ipdb; ipdb.set_trace()
    if not set(s.isdecimal() for s in split) == {True}:
        raise FFReaderError(
            "Indices should be integers and seperated by whitespace", line
        )
    return [int(x) for x in split]


def _validate_params(line: str) -> Tuple[int, List[float]]:
    """
    Check if given line is valid param line, which are
    an integer followed by one or more floats seperated by whitespace
    """
    split = line.split()
    id_ = split[0]
    coeffs = split[1:]
    if not id_.isdecimal():
        raise FFReaderError("Invalid params", line)
    try:
        coeffs = [float(x) for x in coeffs]
    except (TypeError, ValueError):
        raise FFReaderError("Invalid params", line) from None
    return (int(id_), coeffs)

Вопрос

Это кажется большим количеством кода для выполнения простой задачи. Как я могу использовать parsimonious или аналогичные библиотеки синтаксического анализа для упрощения синтаксического анализа таких файлов?

1 Ответ

1 голос
/ 06 мая 2019

Как указано в другом ответе, вы можете использовать библиотеку синтаксического анализа, такую ​​как parsimonious в сочетании с классом NodeVisitor:

from parsimonious.grammar import Grammar
from parsimonious.nodes import NodeVisitor

data = """
############################################
# Comments begin with '#'
############################################

[lj_pairs] # Section 1
    indices:    0 2
    #  ID      eps    sigma
    1       2.344   1.234   5
    2       4.423   5.313   5
    3       1.573   6.321   5
    4       1.921   11.93   5

[bonds]
indices:    0 1
    2   4.234e-03   11.2
    6   -0.134545   5.7
"""

grammar = Grammar(
    r"""
    expr        = (entry / garbage)+
    entry       = section garbage indices (valueline / garbage)*
    section     = lpar word rpar

    indices     = ws? "indices:" values+
    garbage     = ((comment / hs)* newline?)*

    word        = ~"\w+"

    values      = number+
    valueline   = values newline?

    number      = hs? ~"[-.e\d]+" hs?

    lpar        = "["
    rpar        = "]"

    comment     = ~"#.+"
    ws          = ~"\s*"
    hs          = ~"[\t\ ]*"

    newline     = ~"[\r\n]"
    """
)

tree = grammar.parse(data)

class DataVisitor(NodeVisitor):
    def visit_number(self, node, visited_children):
        """ Returns integer and float values. """
        _, value, _ = visited_children
        try:
            number = int(value.text)
        except ValueError:
            number = float(value.text)
        return number

    def visit_section(self, node, visited_children):
        """ Returns the section as text. """
        _, section, _ = visited_children
        return section.text

    def visit_indices(self, node, visited_children):
        """ Returns the index numbers. """
        *_, values = visited_children
        return values[0]

    def visit_valueline(self, node, visited_children):
        """ Returns every value from one line. """
        values, _ = visited_children
        return values

    def visit_entry(self, node, visited_children):
        """ Returns one entry (section, indices, values). """
        section, _, indices, lst = visited_children
        values = [item[0] for item in lst if item[0]]

        return (section, {'indices': indices, 'values': values})

    def visit_expr(self, node, visited_children):
        """ Returns the whole structure as a dict. """
        return dict([item[0] for item in visited_children if item[0]])

    def visit_garbage(self, node, visited_children):
        """ You know what this does. """
        return None

    def generic_visit(self, node, visited_children):
        """ Returns the visited children (if any) or the node itself. """
        return visited_children or node

d = DataVisitor()
result = d.visit(tree)
print(result)

Это даст

{
 'lj_pairs': {'indices': [0, 2], 'values': [[1, 2.344, 1.234, 5], [2, 4.423, 5.313, 5], [3, 1.573, 6.321, 5], [4, 1.921, 11.93, 5]]}, 
 'bonds': {'indices': [0, 1], 'values': [[2, 0.004234, 11.2], [6, -0.134545, 5.7]]}
}


Объяснение

Ваш исходный файл данных может рассматриваться как DSL - d omain s pecific l anguage. Поэтому нам нужна грамматика, которая описывает, как ваш формат может выглядеть. Обычный способ здесь состоит в том, чтобы сначала сформулировать маленькие кубики, такие как пробел или «слово».


В parsimonious у нас есть несколько опций, одна из которых заключается в указании регулярных выражений (они начинаются с ~):
ws          = ~"\s*"

Здесь ws обозначает \s*, что равно нулю или более пробелов.


Другая возможность состоит в буквальном образовании части, такой как
lpar        = "["


Последняя и самая мощная возможность состоит в том, чтобы объединить обе эти меньшие части в одну большую, такую ​​как
section     = lpar word rpar

, что переводится как [word_characters_HERE123] или аналогичная структура.


Теперь применяются обычные чередования (/) и квантификаторы, такие как * (ноль больше, жадный), + (на одну руду больше, жадный) и ? (ноль или один, жадный) и могут быть ставить после каждого выражения, о котором мы можем подумать.

Если все работает нормально и грамматика подходит для данных, которые у нас есть, все разбирается на древовидную структуру, так называемый a bstract s yntax t ree (AST). Для того, чтобы на самом деле сделать что-н. полезный с этой структурой (например, сделайте из этого хороший dict), нам нужно передать его в класс NodeVisitor. Это подвеска к нашей ранее сформированной грамматике, поскольку методы visit_* будут вызывать каждый лист, подходящий для нее. То есть метод visit_section(...) будет вызываться на каждом листе section с соответствующим visited_children.

Давайте сделаем это более понятным. Функция

    def visit_section(self, node, visited_children):
        """ Returns the section as text. """
        _, section, _ = visited_children
        return section.text

будет вызвано для section части нашей грамматики (lpar section rpar), поэтому лист section имеет этих трех детей. Нас не интересует ни [, ни ], а только сам текст раздела, поэтому мы распаковываем и возвращаем section.text.

Нам нужно сделать это для каждого ранее определенного узла / листа. По умолчанию первое определение (в нашем случае expr) и соответствующий visit_expr(...) будут выходными данными класса NodeVisitor, а все остальные узлы являются дочерними (внуки, правнуки и т. Д.) Этого узла.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...