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):