From c8ec92b4d5fc87a86858c4aec0957c494b53ab81 Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Tue, 5 Sep 2023 07:10:02 -0400 Subject: [PATCH] fix: setup.py update using script --- MANIFEST.in | 1 + build/lib/chem/__init__.py | 3 + build/lib/chem/chemcalc.py | 454 ++++++++++++++++++++++++++++++++++++ build/lib/chem/chemtools.py | 139 +++++++++++ build/lib/chem/miller.py | 275 ++++++++++++++++++++++ setup.py | 90 ++++++- 6 files changed, 953 insertions(+), 9 deletions(-) create mode 100644 build/lib/chem/__init__.py create mode 100644 build/lib/chem/chemcalc.py create mode 100644 build/lib/chem/chemtools.py create mode 100644 build/lib/chem/miller.py diff --git a/MANIFEST.in b/MANIFEST.in index 6ec25e7..513cd6f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,4 @@ include LICENSE include README.rst include requirements/base.in include requirements/test.in +include requirements/constraints.txt diff --git a/build/lib/chem/__init__.py b/build/lib/chem/__init__.py new file mode 100644 index 0000000..9136b81 --- /dev/null +++ b/build/lib/chem/__init__.py @@ -0,0 +1,3 @@ +""" init """ + +__version__ = '1.2.0' diff --git a/build/lib/chem/chemcalc.py b/build/lib/chem/chemcalc.py new file mode 100644 index 0000000..d2d9605 --- /dev/null +++ b/build/lib/chem/chemcalc.py @@ -0,0 +1,454 @@ +from fractions import Fraction +from functools import reduce + +import markupsafe +import nltk +from nltk.tree import Tree +from pyparsing import Literal, OneOrMore, ParseException, StringEnd + +ARROWS = ('<->', '->') + +# Defines a simple pyparsing tokenizer for chemical equations +elements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', + 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', + 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', + 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', + 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', + 'Lu', 'Lv', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', + 'Ne', 'Ni', 'No', 'Np', 'O', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', + 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', + 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', + 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'U', 'Uuo', 'Uup', + 'Uus', 'Uut', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'] +digits = list(map(str, list(range(10)))) +symbols = list("[](){}^+-/") +phases = ["(s)", "(l)", "(g)", "(aq)"] +tokens = reduce(lambda a, b: a ^ b, list(map(Literal, elements + digits + symbols + phases))) +tokenizer = OneOrMore(tokens) + StringEnd() + +# HTML, Text are temporarily copied from openedx.core.djangolib.markup +# These libraries need to be moved out of edx-platform to be used by +# other applications. +# See LEARNER-5853 for more details. +Text = markupsafe.escape # pylint: disable=invalid-name + + +def HTML(html): # pylint: disable=invalid-name + return markupsafe.Markup(html) + + +def _orjoin(l): + return "'" + "' | '".join(l) + "'" + + +# Defines an NLTK parser for tokenized expressions +grammar = """ + S -> multimolecule | multimolecule '+' S + multimolecule -> count molecule | molecule + count -> number | number '/' number + molecule -> unphased | unphased phase + unphased -> group | paren_group_round | paren_group_square + element -> """ + _orjoin(elements) + """ + digit -> """ + _orjoin(digits) + """ + phase -> """ + _orjoin(phases) + """ + number -> digit | digit number + group -> suffixed | suffixed group + paren_group_round -> '(' group ')' + paren_group_square -> '[' group ']' + plus_minus -> '+' | '-' + number_suffix -> number + ion_suffix -> '^' number plus_minus | '^' plus_minus + suffix -> number_suffix | number_suffix ion_suffix | ion_suffix + unsuffixed -> element | paren_group_round | paren_group_square + + suffixed -> unsuffixed | unsuffixed suffix +""" +parser = nltk.ChartParser(nltk.CFG.fromstring(grammar)) + + +def _clean_parse_tree(tree): + """ + The parse tree contains a lot of redundant + nodes. E.g. paren_groups have groups as children, etc. This will + clean up the tree. + """ + def unparse_number(n): + """ + Go from a number parse tree to a number + """ + if len(n) == 1: + rv = n[0][0] + else: + rv = n[0][0] + unparse_number(n[1]) + return rv + + def null_tag(n): + """ + Remove a tag + """ + return n[0] + + def ion_suffix(n): + """ + 1. "if" part handles special case + 2. "else" part is general behaviour + """ + if n[1:][0].label() == 'number' and n[1:][0][0][0] == '1': + # if suffix is explicitly 1, like ^1- + # strip 1, leave only sign: ^- + return nltk.tree.Tree(n.label(), n[2:]) + else: + return nltk.tree.Tree(n.label(), n[1:]) + + dispatch = {'number': lambda x: nltk.tree.Tree("number", [unparse_number(x)]), + 'unphased': null_tag, + 'unsuffixed': null_tag, + 'number_suffix': lambda x: nltk.tree.Tree('number_suffix', [unparse_number(x[0])]), + 'suffixed': lambda x: len(x) > 1 and x or x[0], + 'ion_suffix': ion_suffix, + 'paren_group_square': lambda x: nltk.tree.Tree(x.label(), x[1]), + 'paren_group_round': lambda x: nltk.tree.Tree(x.label(), x[1])} + + if isinstance(tree, str): + return tree + + old_node = None + # This loop means that if a node is processed, and returns a child, + # the child will be processed. + while tree.label() in dispatch and tree.label() != old_node: + old_node = tree.label() + tree = dispatch[tree.label()](tree) + + children = [] + for child in tree: + child = _clean_parse_tree(child) + children.append(child) + + tree = nltk.tree.Tree(tree.label(), children) + + return tree + + +def _merge_children(tree, tags): + """ + nltk, by documentation, cannot do arbitrary length groups. + Instead of: (group 1 2 3 4) + It has to handle this recursively: (group 1 (group 2 (group 3 (group 4)))) + We do the cleanup of converting from the latter to the former. + """ + if tree is None: + # There was a problem--shouldn't have empty trees (NOTE: see this with input e.g. 'H2O(', or 'Xe+'). + raise ParseException("Shouldn't have empty trees") + + if isinstance(tree, str): + return tree + + merged_children = [] + done = False + + # Merge current tag + while not done: + done = True + for child in tree: + if isinstance(child, nltk.tree.Tree) and child.label() == tree.label() and tree.label() in tags: + merged_children = merged_children + list(child) + done = False + else: + merged_children = merged_children + [child] + tree = nltk.tree.Tree(tree.label(), merged_children) + merged_children = [] + + # And recurse + children = [] + for child in tree: + children.append(_merge_children(child, tags)) + + return nltk.tree.Tree(tree.label(), children) + + +def _render_to_html(tree): + """ + Renders a cleaned tree to HTML + """ + def molecule_count(tree, children): + # If an integer, return that integer + if len(tree) == 1: + return tree[0][0] + # If a fraction, return the fraction + if len(tree) == 3: + return HTML(" {num}{den} ").format(num=tree[0][0], den=tree[2][0]) + return "Error" + + def subscript(tree, children): + return HTML("{sub}").format(sub=children) + + def superscript(tree, children): + return HTML("{sup}").format(sup=children) + + def round_brackets(tree, children): + return HTML("({insider})").format(insider=children) + + def square_brackets(tree, children): + return HTML("[{insider}]").format(insider=children) + + dispatch = {'count': molecule_count, + 'number_suffix': subscript, + 'ion_suffix': superscript, + 'paren_group_round': round_brackets, + 'paren_group_square': square_brackets} + + if isinstance(tree, str): + return tree + else: + children = HTML("").join(map(_render_to_html, tree)) + if tree.label() in dispatch: + return dispatch[tree.label()](tree, children) + else: + return children.replace(' ', '') + + +def render_to_html(eq): + """ + Render a chemical equation string to html. + + Renders each molecule separately, and returns invalid input wrapped in a . + """ + def err(s): + """ + Render as an error span + """ + return HTML('{0}').format(s) + + def render_arrow(arrow): + """ + Turn text arrows into pretty ones + """ + if arrow == '->': + return HTML('\u2192') + if arrow == '<->': + return HTML('\u2194') + + # this won't be reached unless we add more arrow types, but keep it to avoid explosions when + # that happens. HTML-escape this unknown arrow just in case. + return Text(arrow) + + def render_expression(ex): + """ + Render a chemical expression--no arrows. + """ + try: + return _render_to_html(_get_final_tree(ex)) + except ParseException: + return err(ex) + + def spanify(s): + return HTML('{0}').format(s) + + left, arrow, right = split_on_arrow(eq) + if arrow == '': + # only one side + return spanify(render_expression(left)) + + return spanify(render_expression(left) + render_arrow(arrow) + render_expression(right)) + + +def _get_final_tree(s): + """ + Return final tree after merge and clean. + + Raises pyparsing.ParseException if s is invalid. + """ + try: + tokenized = tokenizer.parseString(s) + parsed = parser.parse(tokenized) + merged = _merge_children(next(parsed), {'S', 'group'}) + final = _clean_parse_tree(merged) + return final + except StopIteration: + # This happens with an empty tree- see this with input e.g. 'H2O(', or 'Xe+'). + raise ParseException("Shouldn't have empty trees") + + +def _check_equality(tuple1, tuple2): + """ + return True if tuples of multimolecules are equal + """ + list1 = list(tuple1) + list2 = list(tuple2) + + # Hypo: trees where are levels count+molecule vs just molecule + # cannot be sorted properly (tested on test_complex_additivity) + # But without factors and phases sorting seems to work. + + # Also for lists of multimolecules without factors and phases + # sorting seems to work fine. + list1.sort() + list2.sort() + return list1 == list2 + + +def compare_chemical_expression(s1, s2, ignore_state=False): + """ + It does comparison between two expressions. + It uses divide_chemical_expression and check if division is 1 + """ + return divide_chemical_expression(s1, s2, ignore_state) == 1 + + +def divide_chemical_expression(s1, s2, ignore_state=False): + """ + Compare two chemical expressions for equivalence up to a multiplicative factor: + + - If they are not the same chemicals, returns False. + - If they are the same, "divide" s1 by s2 to returns a factor x such that s1 / s2 == x as a Fraction object. + - if ignore_state is True, ignores phases when doing the comparison. + + Examples: + divide_chemical_expression("H2O", "3H2O") -> Fraction(1,3) + divide_chemical_expression("3H2O", "H2O") -> 3 # actually Fraction(3, 1), but compares == to 3. + divide_chemical_expression("2H2O(s) + 2CO2", "H2O(s)+CO2") -> 2 + divide_chemical_expression("H2O(s) + CO2", "3H2O(s)+2CO2") -> False + + Implementation sketch: + - extract factors and phases to standalone lists, + - compare expressions without factors and phases, + - divide lists of factors for each other and check + for equality of every element in list, + - return result of factor division + + """ + + # parsed final trees + treedic = { + '1': _get_final_tree(s1), + '2': _get_final_tree(s2) + } + + # strip phases and factors + # collect factors in list + for i in ('1', '2'): + treedic[i + ' cleaned_mm_list'] = [] + treedic[i + ' factors'] = [] + treedic[i + ' phases'] = [] + for el in treedic[i].subtrees(filter=lambda t: t.label() == 'multimolecule'): + count_subtree = [t for t in el.subtrees() if t.label() == 'count'] + group_subtree = [t for t in el.subtrees() if t.label() == 'group'] + phase_subtree = [t for t in el.subtrees() if t.label() == 'phase'] + if count_subtree: + if len(count_subtree[0]) > 1: + treedic[i + ' factors'].append( + int(count_subtree[0][0][0]) / + int(count_subtree[0][2][0])) + else: + treedic[i + ' factors'].append(int(count_subtree[0][0][0])) + else: + treedic[i + ' factors'].append(1.0) + if phase_subtree: + treedic[i + ' phases'].append(phase_subtree[0][0]) + else: + treedic[i + ' phases'].append(' ') + treedic[i + ' cleaned_mm_list'].append( + Tree('multimolecule', [Tree('molecule', group_subtree)])) + + # order of factors and phases must mirror the order of multimolecules, + # use 'decorate, sort, undecorate' pattern + treedic['1 cleaned_mm_list'], treedic['1 factors'], treedic['1 phases'] = list(zip( + *sorted(zip(treedic['1 cleaned_mm_list'], treedic['1 factors'], treedic['1 phases'])))) + + treedic['2 cleaned_mm_list'], treedic['2 factors'], treedic['2 phases'] = list(zip( + *sorted(zip(treedic['2 cleaned_mm_list'], treedic['2 factors'], treedic['2 phases'])))) + + # check if expressions are correct without factors + if not _check_equality(treedic['1 cleaned_mm_list'], treedic['2 cleaned_mm_list']): + return False + + # phases are ruled by ingore_state flag + if not ignore_state: # phases matters + if treedic['1 phases'] != treedic['2 phases']: + return False + + if any( + [ + x / y - treedic['1 factors'][0] / treedic['2 factors'][0] + for (x, y) in zip(treedic['1 factors'], treedic['2 factors']) + ] + ): + # factors are not proportional + return False + else: + # return ratio + return Fraction(treedic['1 factors'][0] / treedic['2 factors'][0]) + + +def split_on_arrow(eq): + """ + Split a string on an arrow. Returns left, arrow, right. If there is no arrow, returns the + entire eq in left, and '' in arrow and right. + + Return left, arrow, right. + """ + # order matters -- need to try <-> first + for arrow in ARROWS: + left, a, right = eq.partition(arrow) + if a != '': + return left, a, right + + return eq, '', '' + + +def chemical_equations_equal(eq1, eq2, exact=False): + """ + Check whether two chemical equations are the same. (equations have arrows) + + If exact is False, then they are considered equal if they differ by a + constant factor. + + arrows matter: -> and <-> are different. + + e.g. + chemical_equations_equal('H2 + O2 -> H2O2', 'O2 + H2 -> H2O2') -> True + chemical_equations_equal('H2 + O2 -> H2O2', 'O2 + 2H2 -> H2O2') -> False + + chemical_equations_equal('H2 + O2 -> H2O2', 'O2 + H2 <-> H2O2') -> False + + chemical_equations_equal('H2 + O2 -> H2O2', '2 H2 + 2 O2 -> 2 H2O2') -> True + chemical_equations_equal('H2 + O2 -> H2O2', '2 H2 + 2 O2 -> 2 H2O2', exact=True) -> False + + + If there's a syntax error, we return False. + """ + + left1, arrow1, right1 = split_on_arrow(eq1) + left2, arrow2, right2 = split_on_arrow(eq2) + + if arrow1 == '' or arrow2 == '': + return False + + # TODO: may want to be able to give student helpful feedback about why things didn't work. + if arrow1 != arrow2: + # arrows don't match + return False + + try: + factor_left = divide_chemical_expression(left1, left2) + if not factor_left: + # left sides don't match + return False + + factor_right = divide_chemical_expression(right1, right2) + if not factor_right: + # right sides don't match + return False + + if factor_left != factor_right: + # factors don't match (molecule counts to add up) + return False + + if exact and factor_left != 1: + # want an exact match. + return False + + return True + except ParseException: + # Don't want external users to have to deal with parsing exceptions. Just return False. + return False diff --git a/build/lib/chem/chemtools.py b/build/lib/chem/chemtools.py new file mode 100644 index 0000000..1e6b934 --- /dev/null +++ b/build/lib/chem/chemtools.py @@ -0,0 +1,139 @@ +"""This module originally includes functions for grading Vsepr problems. + +Also, may be this module is the place for other chemistry-related grade functions. TODO: discuss it. +""" + +import itertools +import json + + +def vsepr_parse_user_answer(user_input): + """ + user_input is json generated by vsepr.js from dictionary. + There are must be only two keys in original user_input dictionary: "geometry" and "atoms". + Format: u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}' + Order of elements inside "atoms" subdict does not matters. + Return dict from parsed json. + + "Atoms" subdict stores positions of atoms in molecule. + General types of positions: + c0 - central atom + p0..pN - peripheral atoms + a0..aN - axial atoms + e0..eN - equatorial atoms + + Each position is dictionary key, i.e. user_input["atoms"]["c0"] is central atom, user_input["atoms"]["a0"] is one of axial atoms. + + Special position only for AX6 (Octahedral) geometry: + e10, e12 - atom pairs opposite the central atom, + e20, e22 - atom pairs opposite the central atom, + e1 and e2 pairs lying crosswise in equatorial plane. + + In user_input["atoms"] may be only 3 set of keys: + (c0,p0..pN), + (c0, a0..aN, e0..eN), + (c0, a0, a1, e10,e11,e20,e21) - if geometry is AX6. + """ + return json.loads(user_input) + + +def vsepr_build_correct_answer(geometry, atoms): + """ + geometry is string. + atoms is dict of atoms with proper positions. + Example: + + correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"}) + + returns a dictionary composed from input values: + {'geometry': geometry, 'atoms': atoms} + """ + return {'geometry': geometry, 'atoms': atoms} + + +def vsepr_grade(user_input, correct_answer, convert_to_peripheral=False): + """ + This function does comparison between user_input and correct_answer. + + Comparison is successful if all steps are successful: + + 1) geometries are equal + 2) central atoms (index in dictionary 'c0') are equal + 3): + In next steps there is comparing of corresponding subsets of atom positions: equatorial (e0..eN), axial (a0..aN) or peripheral (p0..pN) + + If convert_to_peripheral is True, then axial and equatorial positions are converted to peripheral. + This means that user_input from: + "atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}' after parsing to json + is converted to: + {"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"} + i.e. aX and eX -> pX + + So if converted, p subsets are compared, + if not a and e subsets are compared + If all subsets are equal, grade succeeds. + + There is also one special case for AX6 geometry. + In this case user_input["atoms"] contains special 3 symbol keys: e10, e12, e20, and e21. + Correct answer for this geometry can be of 3 types: + 1) c0 and peripheral + 2) c0 and axial and equatorial + 3) c0 and axial and equatorial-subset-1 (e1X) and equatorial-subset-2 (e2X) + + If correct answer is type 1 or 2, then user_input is converted from type 3 to type 2 (or to type 1 if convert_to_peripheral is True) + + If correct_answer is type 3, then we done special case comparison. We have 3 sets of atoms positions both in user_input and correct_answer: axial, eq-1 and eq-2. + Answer will be correct if these sets are equals for one of permutations. For example, if : + user_axial = correct_eq-1 + user_eq-1 = correct-axial + user_eq-2 = correct-eq-2 + + """ + if user_input['geometry'] != correct_answer['geometry']: + return False + + if user_input['atoms']['c0'] != correct_answer['atoms']['c0']: + return False + + if convert_to_peripheral: + # convert user_input from (a,e,e1,e2) to (p) + # correct_answer must be set in (p) using this flag + c0 = user_input['atoms'].pop('c0') + user_input['atoms'] = {'p' + str(i): v for i, v in enumerate(user_input['atoms'].values())} + user_input['atoms']['c0'] = c0 + + # special case for AX6 + if 'e10' in correct_answer['atoms']: # need check e1x, e2x symmetry for AX6.. + a_user = {} + a_correct = {} + for ea_position in ['a', 'e1', 'e2']: # collecting positions: + a_user[ea_position] = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)] + a_correct[ea_position] = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)] + + correct = [sorted(a_correct['a'])] + [sorted(a_correct['e1'])] + [sorted(a_correct['e2'])] + for permutation in itertools.permutations(['a', 'e1', 'e2']): + if correct == [sorted(a_user[permutation[0]])] + [sorted(a_user[permutation[1]])] + [sorted(a_user[permutation[2]])]: + return True + return False + + else: # no need to check e1x,e2x symmetry - convert them to ex + if 'e10' in user_input['atoms']: # e1x exists, it is AX6.. case + e_index = 0 + for k, v in user_input['atoms'].items(): + if len(k) == 3: # e1x + del user_input['atoms'][k] + user_input['atoms']['e' + str(e_index)] = v + e_index += 1 + + # common case + for ea_position in ['p', 'a', 'e']: + # collecting atoms: + a_user = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)] + a_correct = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)] + # print a_user, a_correct + if len(a_user) != len(a_correct): + return False + if sorted(a_user) != sorted(a_correct): + return False + + return True diff --git a/build/lib/chem/miller.py b/build/lib/chem/miller.py new file mode 100644 index 0000000..86b567d --- /dev/null +++ b/build/lib/chem/miller.py @@ -0,0 +1,275 @@ +""" Calculation of Miller indices """ + + +import decimal +import fractions as fr +import json +import math +from functools import reduce + +import numpy as np + + +def lcm(a, b): + """ + Returns least common multiple of a, b + + Args: + a, b: floats + + Returns: + float + """ + return a * b / fr.gcd(a, b) + + +def segment_to_fraction(distance): + """ + Converts lengths of which the plane cuts the axes to fraction. + + Tries convert distance to closest nicest fraction with denominator less or + equal than 10. It is + purely for simplicity and clearance of learning purposes. Jenny: 'In typical + courses students usually do not encounter indices any higher than 6'. + + If distance is not a number (numpy nan), it means that plane is parallel to + axis or contains it. Inverted fraction to nan (nan is 1/0) = 0 / 1 is + returned + + Generally (special cases): + + a) if distance is smaller than some constant, i.g. 0.01011, + than fraction's denominator usually much greater than 10. + + b) Also, if student will set point on 0.66 -> 1/3, so it is 333 plane, + But if he will slightly move the mouse and click on 0.65 -> it will be + (16,15,16) plane. That's why we are doing adjustments for points coordinates, + to the closest tick, tick + tick / 2 value. And now UI sends to server only + values multiple to 0.05 (half of tick). Same rounding is implemented for + unittests. + + But if one will want to calculate miller indices with exact coordinates and + with nice fractions (which produce small Miller indices), he may want shift + to new origin if segments are like S = (0.015, > 0.05, >0.05) - close to zero + in one coordinate. He may update S to (0, >0.05, >0.05) and shift origin. + In this way he can receive nice small fractions. Also there is can be + degenerated case when S = (0.015, 0.012, >0.05) - if update S to (0, 0, >0.05) - + it is a line. This case should be considered separately. Small nice Miller + numbers and possibility to create very small segments can not be implemented + at same time). + + + Args: + distance: float distance that plane cuts on axis, it must not be 0. + Distance is multiple of 0.05. + + Returns: + Inverted fraction. + 0 / 1 if distance is nan + + """ + if np.isnan(distance): + return fr.Fraction(0, 1) + else: + fract = fr.Fraction(distance).limit_denominator(10) + return fr.Fraction(fract.denominator, fract.numerator) + + +def sub_miller(segments): + ''' + Calculates Miller indices from segments. + + Algorithm: + + 1. Obtain inverted fraction from segments + + 2. Find common denominator of inverted fractions + + 3. Lead fractions to common denominator and throws denominator away. + + 4. Return obtained values. + + Args: + List of 3 floats, meaning distances that plane cuts on x, y, z axes. + Any float not equals zero, it means that plane does not intersect origin, + i. e. shift of origin has already been done. + + Returns: + String that represents Miller indices, e.g: (-6,3,-6) or (2,2,2) + ''' + fracts = [segment_to_fraction(segment) for segment in segments] + common_denominator = reduce(lcm, [fract.denominator for fract in fracts]) + miller_indices = ([ + fract.numerator * math.fabs(common_denominator) / fract.denominator + for fract in fracts + ]) + return'(' + ','.join(map(str, list(map(decimal.Decimal, miller_indices)))) + ')' + + +def miller(points): + """ + Calculates Miller indices from points. + + Algorithm: + + 1. Calculate normal vector to a plane that goes trough all points. + + 2. Set origin. + + 3. Create Cartesian coordinate system (Ccs). + + 4. Find the lengths of segments of which the plane cuts the axes. Equation + of a line for axes: Origin + (Coordinate_vector - Origin) * parameter. + + 5. If plane goes trough Origin: + + a) Find new random origin: find unit cube vertex, not crossed by a plane. + + b) Repeat 2-4. + + c) Fix signs of segments after Origin shift. This means to consider + original directions of axes. I.g.: Origin was 0,0,0 and became + new_origin. If new_origin has same Y coordinate as Origin, then segment + does not change its sign. But if new_origin has another Y coordinate than + origin (was 0, became 1), than segment has to change its sign (it now + lies on negative side of Y axis). New Origin 0 value of X or Y or Z + coordinate means that segment does not change sign, 1 value -> does + change. So new sign is (1 - 2 * new_origin): 0 -> 1, 1 -> -1 + + 6. Run function that calculates miller indices from segments. + + Args: + List of points. Each point is list of float coordinates. Order of + coordinates in point's list: x, y, z. Points are different! + + Returns: + String that represents Miller indices, e.g: (-6,3,-6) or (2,2,2) + """ + + N = np.cross(points[1] - points[0], points[2] - points[0]) + O = np.array([0, 0, 0]) + P = points[0] # point of plane + Ccs = list(map(np.array, [[1.0, 0, 0], [0, 1.0, 0], [0, 0, 1.0]])) + segments = ([ + np.dot(P - O, N) / np.dot(ort, N) if np.dot(ort, N) != 0 + else np.nan for ort in Ccs + ]) + if any(x == 0 for x in segments): # Plane goes through origin. + vertices = [ + # top: + np.array([1.0, 1.0, 1.0]), + np.array([0.0, 0.0, 1.0]), + np.array([1.0, 0.0, 1.0]), + np.array([0.0, 1.0, 1.0]), + # bottom, except 0,0,0: + np.array([1.0, 0.0, 0.0]), + np.array([0.0, 1.0, 0.0]), + np.array([1.0, 1.0, 1.0]), + ] + for vertex in vertices: + if np.dot(vertex - O, N) != 0: # vertex not in plane + new_origin = vertex + break + # obtain new axes with center in new origin + X = np.array([1 - new_origin[0], new_origin[1], new_origin[2]]) + Y = np.array([new_origin[0], 1 - new_origin[1], new_origin[2]]) + Z = np.array([new_origin[0], new_origin[1], 1 - new_origin[2]]) + new_Ccs = [X - new_origin, Y - new_origin, Z - new_origin] + segments = ([np.dot(P - new_origin, N) / np.dot(ort, N) if + np.dot(ort, N) != 0 else np.nan for ort in new_Ccs]) + # fix signs of indices: 0 -> 1, 1 -> -1 ( + segments = (1 - 2 * new_origin) * segments + + return sub_miller(segments) + + +def grade(user_input, correct_answer): + ''' + Grade crystallography problem. + + Returns true if lattices are the same and Miller indices are same or minus + same. E.g. (2,2,2) = (2, 2, 2) or (-2, -2, -2). Because sign depends only + on student's selection of origin. + + Args: + user_input, correct_answer: json. Format: + + user_input: {"lattice":"sc","points":[["0.77","0.00","1.00"], + ["0.78","1.00","0.00"],["0.00","1.00","0.72"]]} + + correct_answer: {'miller': '(00-1)', 'lattice': 'bcc'} + + "lattice" is one of: "", "sc", "bcc", "fcc" + + Returns: + True or false. + ''' + def negative(m): + """ + Change sign of Miller indices. + + Args: + m: string with meaning of Miller indices. E.g.: + (-6,3,-6) -> (6, -3, 6) + + Returns: + String with changed signs. + """ + output = '' + i = 1 + while i in range(1, len(m) - 1): + if m[i] in (',', ' '): + output += m[i] + elif m[i] not in ('-', '0'): + output += '-' + m[i] + elif m[i] == '0': + output += m[i] + else: + i += 1 + output += m[i] + i += 1 + return '(' + output + ')' + + def round0_25(point): + """ + Rounds point coordinates to closest 0.5 value. + + Args: + point: list of float coordinates. Order of coordinates: x, y, z. + + Returns: + list of coordinates rounded to closes 0.5 value + """ + rounded_points = [] + for coord in point: + base = math.floor(coord * 10) + fractional_part = (coord * 10 - base) + aliquot0_25 = math.floor(fractional_part / 0.25) + if aliquot0_25 == 0.0: + rounded_points.append(base / 10) + if aliquot0_25 in (1.0, 2.0): + rounded_points.append(base / 10 + 0.05) + if aliquot0_25 == 3.0: + rounded_points.append(base / 10 + 0.1) + return rounded_points + + user_answer = json.loads(user_input) + + if user_answer['lattice'] != correct_answer['lattice']: + return False + + points = [list(map(float, p)) for p in user_answer['points']] + + if len(points) < 3: + return False + + # round point to closes 0.05 value + points = [round0_25(point) for point in points] + + points = [np.array(point) for point in points] + # print miller(points), (correct_answer['miller'].replace(' ', ''), + # negative(correct_answer['miller']).replace(' ', '')) + if miller(points) in (correct_answer['miller'].replace(' ', ''), negative(correct_answer['miller']).replace(' ', '')): + return True + + return False diff --git a/setup.py b/setup.py index 29a381c..3997f10 100644 --- a/setup.py +++ b/setup.py @@ -10,24 +10,96 @@ def load_requirements(*requirements_paths): """ Load all requirements from the specified requirements files. + + Requirements will include any constraints from files specified + with -c in the requirements files. Returns a list of requirement strings. """ - requirements = set() + # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why. + + # e.g. {"django": "Django", "confluent-kafka": "confluent_kafka[avro]"} + by_canonical_name = {} + + def check_name_consistent(package): + """ + Raise exception if package is named different ways. + + This ensures that packages are named consistently so we can match + constraints to packages. It also ensures that if we require a package + with extras we don't constrain it without mentioning the extras (since + that too would interfere with matching constraints.) + """ + canonical = package.lower().replace('_', '-').split('[')[0] + seen_spelling = by_canonical_name.get(canonical) + if seen_spelling is None: + by_canonical_name[canonical] = package + elif seen_spelling != package: + raise Exception( + f'Encountered both "{seen_spelling}" and "{package}" in requirements ' + 'and constraints files; please use just one or the other.' + ) + + requirements = {} + constraint_files = set() + + # groups "pkg<=x.y.z,..." into ("pkg", "<=x.y.z,...") + re_package_name_base_chars = r"a-zA-Z0-9\-_." # chars allowed in base package name + # Two groups: name[maybe,extras], and optionally a constraint + requirement_line_regex = re.compile( + r"([%s]+(?:\[[%s,\s]+\])?)([<>=][^#\s]+)?" + % (re_package_name_base_chars, re_package_name_base_chars) + ) + + def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present): + regex_match = requirement_line_regex.match(current_line) + if regex_match: + package = regex_match.group(1) + version_constraints = regex_match.group(2) + check_name_consistent(package) + existing_version_constraints = current_requirements.get(package, None) + # It's fine to add constraints to an unconstrained package, + # but raise an error if there are already constraints in place. + if existing_version_constraints and existing_version_constraints != version_constraints: + raise BaseException(f'Multiple constraint definitions found for {package}:' + f' "{existing_version_constraints}" and "{version_constraints}".' + f'Combine constraints into one location with {package}' + f'{existing_version_constraints},{version_constraints}.') + if add_if_not_present or package in current_requirements: + current_requirements[package] = version_constraints + + # Read requirements from .in files and store the path to any + # constraint files that are pulled in. for path in requirements_paths: with open(path) as reqs: - requirements.update( - line.split('#')[0].strip() for line in reqs - if is_requirement(line.strip()) - ) - return list(requirements) + for line in reqs: + if is_requirement(line): + add_version_constraint_or_raise(line, requirements, True) + if line and line.startswith('-c') and not line.startswith('-c http'): + constraint_files.add(os.path.dirname(path) + '/' + line.split('#')[0].replace('-c', '').strip()) + + # process constraint files: add constraints to existing requirements + for constraint_file in constraint_files: + with open(constraint_file) as reader: + for line in reader: + if is_requirement(line): + add_version_constraint_or_raise(line, requirements, False) + + # process back into list of pkg><=constraints strings + constrained_requirements = [f'{pkg}{version or ""}' for (pkg, version) in sorted(requirements.items())] + return constrained_requirements def is_requirement(line): """ - Return True if the requirement line is a package requirement; - that is, it is not blank, a comment, a URL, or an included file. + Return True if the requirement line is a package requirement. + + Returns: + bool: True if the line is not blank, a comment, + a URL, or an included file """ - return line and not line.startswith(('-r', '#', '-e', 'git+', '-c')) + # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why + + return line and line.strip() and not line.startswith(('-r', '#', '-e', 'git+', '-c')) def get_version(*file_paths):