From 0dafb0212f9d230450860a73d4529f91333b173e Mon Sep 17 00:00:00 2001 From: Pol Febrer Date: Tue, 18 Jan 2022 01:02:45 +0100 Subject: [PATCH 1/8] new paradigm --- src/sisl/geom/composite.py | 68 +++++ src/sisl/geom/nanoribbon.py | 474 ++++++++++++++++++++++++++++++- src/sisl/geom/tests/test_geom.py | 58 ++++ 3 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 src/sisl/geom/composite.py diff --git a/src/sisl/geom/composite.py b/src/sisl/geom/composite.py new file mode 100644 index 0000000000..d55bdc2b80 --- /dev/null +++ b/src/sisl/geom/composite.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass, copy, fields + +from sisl.messages import warn + + +@dataclass +class _geom_section: + + def _junction_error(self, prev, msg, what): + """Helper function to raise an error if the junction is not valid. + + It extends the error by specifying details about the sections that + are being joined. + """ + msg = f"Error at junction between sections {prev} and {self}. {msg}" + if what == "raise": + raise ValueError(msg) + elif what == "warn": + warn(msg) + + +def composite_geometry(sections, section_cls=_geom_section, **kwargs): + """Creates a composite geometry from a list of sections. + + The sections are added one after another in the provided order. + + Parameters + ---------- + sections: array-like of (_geom_section or tuple or dict) + A list of sections to be added to the ribbon. + + Each section is either a `_geom_section` or something that will + be parsed to a `_geom_section`. + section_cls: class, optional + The class to use for parsing sections. + **kwargs: + Keyword arguments used as defaults for the sections when the . + """ + # Parse sections into Section objects + def conv(s): + # If it is some arbitrary type, convert it to a tuple + if not isinstance(s, (section_cls, tuple, dict)): + s = (s, ) + # If we arrived here with a tuple, convert it to a dict + if isinstance(s, tuple): + s = {field.name: val for field, val in zip(fields(section_cls), s)} + # At this point it is either a dict or already a section object. + if isinstance(s, dict): + return section_cls(**{**kwargs, **s}) + + return copy.copy(s) + + sections = [conv(section) for section in sections] + + # Then loop through all the sections. + geom = None + prev = None + for i, section in enumerate(sections): + new_addition = section.build_section(prev) + + if i == 0: + geom = new_addition + else: + geom = section.add_section(geom, new_addition) + + prev = section + + return geom diff --git a/src/sisl/geom/nanoribbon.py b/src/sisl/geom/nanoribbon.py index 3844c39673..664243863d 100644 --- a/src/sisl/geom/nanoribbon.py +++ b/src/sisl/geom/nanoribbon.py @@ -1,16 +1,21 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from dataclasses import dataclass, field from numbers import Integral import numpy as np from sisl import Atom, geom +from .composite import _geom_section, composite_geometry from sisl._internal import set_module from ._common import geometry_define_nsc -__all__ = ['nanoribbon', 'graphene_nanoribbon', 'agnr', 'zgnr'] +__all__ = [ + 'nanoribbon', 'graphene_nanoribbon', 'agnr', 'zgnr', + 'heteroribbon', 'graphene_heteroribbon', '_heteroribbon_section' +] @set_module("sisl.geom") @@ -159,3 +164,470 @@ def zgnr(width, bond=1.42, atoms=None): agnr : armchair graphene nanoribbon """ return graphene_nanoribbon(width, bond, atoms, kind='zigzag') + +@set_module("sisl.geom") +@dataclass +class _heteroribbon_section(_geom_section): + """ + Parameters + ---------- + W: int + The width of the section. + L: int, optional + The number of units of the section. Note that a "unit" is + not a unit cell, but half of it. I.e. a transversal string of + atoms. + shift: int, optional + The shift of this section with respect to the previous one. + It can be both positive (upwards shift) or negative (downwards shift). + align: {"bottom"/"b", "top"/"t", "center"/"c", "auto"/"a"} + Indicates how the section should be aligned with respect to the + previous one. + + If automatic alignment is requested, sections are aligned: + - If both sections are odd: On their center. + - If previous section is even: On its open edge (top or bottom) + - If previous section is odd and incoming section is even: On the bottom. + atoms: Atom + Value to pass to the `atoms` argument of `nanoribbon`. If not provided, + it defaults to the `atoms` argument passed to this function. + bond: float + The bond length of the ribbon. + kind: {'armchair', 'zigzag'} + The kind of ribbon that this section should be. + shift_quantum: bool, optional + Whether the implementation will assist avoiding lone atoms (< 2 neighbours). + + If ``False``, sections are just shifted (`shift`) number of atoms. + + If ``True``, shifts are quantized in the sense that shifts that produce + lone atoms (< 2 neighbours) are ignored. Then: + - ``shift = 0`` means aligned. + - ``shift = -1`` means first possible downwards shift (if available). + - ``shift = 1`` means first possible upwards shift (if available). + If this is set to `True`, `on_lone_atom` is overwritten to `"raise"`. + on_lone_atom: {'ignore', 'warn', 'raise'} + What to do when a junction between sections produces lone atoms (< 2 neighbours) + + Messages contain hopefully useful explanations to understand what + to do to fix it. + invert_first: bool, optional + Whether, if this is the first section, it should be inverted with respect + to the one provided by `sisl.geom.nanoribbon`. + """ + W: int + L: int = 1 + shift: int = 0 + align: str = "bottom" + atoms: Atom = None + bond: float = None + kind: str = "armchair" + shift_quantum: bool = False + on_lone_atom: str = field(default="ignore", repr=False) + invert_first: bool = field(default=False, repr=False) + + def __post_init__(self): + if self.kind == "armchair": + self.long_ax, self.trans_ax = 0, 1 + elif self.kind == "zigzag": + self.long_ax, self.trans_ax = 1, 0 + else: + raise ValueError("Unknown kind={kind}, must be one of zigzag or armchair") + + if self.shift_quantum: + self.on_lone_atom = "raise" + + self._open_borders = [False, False] + + def _shift_unit_cell(self, geometry): + """Changes the border used for a ribbon. + + It does so by shifting half unit cell. This must be done before any + tiling of the geometry. + """ + geometry = geometry.move(geometry.cell[self.long_ax] / 2) + geometry.xyz = (geometry.fxyz % 1).dot(geometry.cell) + return geometry + + def _align_offset(self, prev, new_xyz): + """Helper function to align the sections. + + It returns the offset to apply to the incoming section in order to + align it to the previous one. + """ + align = self.align.lower() + if prev is None: + return align, 0 + + W = self.W + W_diff = W - prev.W + if align in ("a", "auto"): + if (W % 2 == 1 and W_diff % 2 == 0): + # Both ribbons are odd, so we align on the center + align = "c" + elif prev.W % 2 == 0: + # The previous section is even, so it dictates how to align. + # We should align on its open edge. + align = {True: "t", False: "b"}[prev._open_borders[1]] + else: + # We have an incoming even section and we can align it however we wish. + # We will align them on the bottom. + align = "b" + + if align in ("c", "center"): + if W_diff % 2 == 1: + self._junction_error(prev, "Different parity sections can not be aligned by their centers", "raise") + return align, prev.xyz[:, self.trans_ax].mean() - new_xyz[:, self.trans_ax].mean() + elif align in ("t", "top"): + return align, prev.xyz[:, self.trans_ax].max() - new_xyz[:, self.trans_ax].max() + elif align in ("b", "bottom"): + return align, prev.xyz[:, self.trans_ax].min() - new_xyz[:, self.trans_ax].min() + else: + raise ValueError(f"Invalid value for 'align': {align}. Must be one of" + " {'c', 'center', 't', 'top', 'b', 'bottom', 'a', 'auto'}") + + def _offset_from_center(self, align, prev): + align = align.lower()[0] + W_diff = self.W - prev.W + + if align in ("t", "b"): + # Number of atoms that hang out if we align on the center + edge_offset = W_diff // 2 + + # Now shift the limits. + if align == "t": + offset_sign = -1 + elif align == "b": + offset_sign = 1 + return edge_offset * offset_sign + + return 0 + + def build_section(self, prev): + new_section = nanoribbon( + bond=self.bond, atoms=self.atoms, + width=self.W, kind=self.kind + ) + + align, offset = self._align_offset(prev, new_section) + + if prev is not None: + if not isinstance(prev, _heteroribbon_section): + self._junction_error(prev, f"{self.__class__.__name__} can not be appended to {type(prev).__name__}", "raise") + if self.kind != prev.kind: + self._junction_error(prev, f"Ribbons must be of same type.", "raise") + if self.bond != prev.bond: + self._junction_error(prev, f"Ribbons must have same bond length.", "raise") + + shift = self._parse_shift(self.shift, prev, align) + + # Get the distance of an atom shift. (sin(60) = 3**.5 / 2) + atom_shift = self.bond * 3**.5 / 2 + + # if (last_W % 2 == 1 and W < last_W) and last_open: + # _junction_error(i, "DANGLING BONDS: Previous odd section, which has an open end," + # " is wider than the incoming one. A wider odd section must always" + # " have a closed end. You can solve this by making the previous section" + # " one unit smaller or larger (L = L +- 1).", on_lone_atom + # ) + W_diff = self.W - prev.W + + # Check whether aligned sections naturally match + if align in ("c", "center"): + # When sections are aligned by the center or the top, it is very easy to check if + # they match. + aligned_match = (not prev._open_borders[1]) == (W_diff % 4 == 0) + elif align in ("t", "top"): + aligned_match = not prev._open_borders[1] + elif align in ("b", "bottom"): + # However, when sections are aligned by the bottom, it is a bit more complex. + # This is because a "closed" even section means that its bottom edge is open. + last_bot_edge_open = (prev.W % 2 == 1) == prev._open_borders[1] + this_bot_edge_open = self.W % 2 == 0 + aligned_match = last_bot_edge_open == this_bot_edge_open + + # Shift the incoming section if the vertical shift makes them not match. + if aligned_match == (shift % 2 == 1): + new_section = self._shift_unit_cell(new_section) + self._open_borders[0] = not self._open_borders[0] + + # Apply the offset that we have calculated. + move = np.zeros(3) + move[self.trans_ax] = offset + shift * atom_shift + new_section = new_section.move(move) + else: + if self.invert_first: + new_section = self._shift_unit_cell(new_section) + self._open_borders[0] = not self._open_borders[0] + + # Check how many times we have to tile the unit cell (tiles) and whether + # we have to cut the last string of atoms (cut_last) + tiles, res = divmod(self.L + 1, 2) + cut_last = res == 0 + + # Tile the current section unit cell + new_section = new_section.tile(tiles, self.long_ax) + # Cut the last string of atoms. + if cut_last: + new_section.cell[0, 0] *= self.L / (self.L + 1) + new_section = new_section.remove({"xy"[self.long_ax]: + (new_section.cell[self.long_ax, self.long_ax] - 0.01, None)}) + + self._open_borders[1] = self._open_borders[0] != cut_last + + self.xyz = new_section.xyz + return new_section + + def add_section(self, geom, new_section): + # Avoid going out of the cell in the transversal direction + new_min = new_section[:, self.trans_ax].min() + new_max = new_section[:, self.trans_ax].max() + if new_min < 0: + cell_offset = - new_min + 14 + geom = geom.add_vacuum(cell_offset, self.trans_ax) + move = np.zeros(3) + move[self.trans_ax] = cell_offset + geom = geom.move(move) + new_section = new_section.move(move) + if new_max > geom.cell[1, 1]: + geom = geom.add_vacuum(new_max - geom.cell[self.trans_ax, self.trans_ax] + 14, self.trans_ax) + + self.xyz = new_section.xyz + # Finally, we can safely append the geometry. + return geom.append(new_section, self.long_ax) + + def _parse_shift(self, shift, prev, align): + if self.on_lone_atom == "ignore": + return shift + + W = self.W + + # Check that we are not joining an open odd ribbon with + # a smaller ribbon, since no matter what the shift is there will + # always be dangling bonds. + if (prev.W % 2 == 1 and W < prev.W) and prev._open_borders[1]: + self._junction_error(prev, "LONE ATOMS: Previous odd section, which has an open end," + " is wider than the incoming one. A wider odd section must always" + " have a closed end. You can solve this by making the previous section" + " one unit smaller or larger (L = L +- 1).", "raise" + ) + + # Get the difference in width between the previous and this ribbon section + W_diff = W - prev.W + # And also the mod(4) because there are certain differences if the width differs + # on 1, 2, 3 or 4 atoms. After that, the cycle just repeats (e.g. 5 == 1, etc). + diff_mod = W_diff % 4 + + # Now, we need to calculate the offset that we have to apply to the incoming + # section depending on several factors. + if diff_mod % 2 == 0 and W % 2 == 1: + # Both sections are odd + + if W < prev.W: + # The incoming section is thinner than the last one. Note that at this point + # we are sure that the last section has a closed border, otherwise we + # would have raised an error. At this point, centers can differ by any + # integer number of atoms without leaving dangling bonds. + + # Shift limits are a bit complicated and are different for even and odd shifts. + # This is because a closed incoming section can shift until there is no connection + # between ribbons, while an open one needs to stop before its edge goes outside + # the previous section. + shift_lims = { + "closed": prev.W // 2 + W // 2 - 2, + "open": prev.W // 2 - W // 2 - 1 + } + + shift_pars = { + lim % 2: lim for k, lim in shift_lims.items() + } + + # Build an array with all the valid shifts. + valid_shifts = np.sort([*np.arange(0, shift_pars[0] + 1, 2), *np.arange(1, shift_pars[1] + 1, 2)]) + valid_shifts = np.array([*(np.flip(-valid_shifts)[:-1]), *valid_shifts]) + + # Update the valid shift limits if the sections are aligned on any of the edges. + shift_offset = self._offset_from_center(align, prev) + valid_shifts += shift_offset + elif prev.W == W: + valid_shifts = np.array([0]) + else: + # At this point, we already know that the incoming section is wider and + # therefore it MUST have a closed start, otherwise there will be dangling bonds. + if diff_mod == 2 and prev._open_borders[1] or diff_mod == 0 and not prev._open_borders[1]: + # In these cases, centers must match or differ by an even number of atoms. + # And this is the limit for the shift from the center. + shift_lim = ((W_diff // 2) // 2) * 2 + else: + # Otherwise, centers must differ by an odd number of atoms. + # And these are the limits for the shift from the center + if prev._open_borders[1]: + # To avoid the current open section leaving dangling bonds. + shift_lim = (W_diff // 2) - 1 + else: + # To avoid sections being disconnected. + shift_lim = W_diff // 2 + ((prev.W // 2) - 1) * 2 + + # Update the shift limits if the sections are aligned on any of the edges. + shift_offset = self._offset_from_center(align, prev) + + # Apply the offsets and calculate the maximum and minimum shifts. + min_shift, max_shift = -shift_lim + shift_offset, shift_lim + shift_offset + + valid_shifts = np.arange(min_shift, max_shift + 1, 2) + else: + # There is at least one even section. + + # We have to make sure that the open edge of the even ribbons (however + # many there are) is always shifted towards the center. Shifting in the + # opposite direction would result in dangling bonds. + + # We will calculate all the valid shifts from a bottom alignment perspective. + # Then convert if needed. + special_shifts = [] + + if diff_mod % 2 == 0: + # Both ribbons are even + if prev._open_borders[1]: + special_shifts = [prev.W - W] + min_shift = prev.W - W + 1 + max_shift = prev.W - 1 + else: + special_shifts = [0] + min_shift = - W + 1 + max_shift = -1 + + elif W % 2 == 1: + # Last section was even, incoming section is odd. + if W < prev.W: + if prev._open_borders[1]: + special_shifts = [prev.W - W] + min_shift = prev.W - W + max_shift = prev.W - W + 1 + ((W - 2) // 2)*2 + else: + special_shifts = [0] + min_shift = -1 - ((W - 2) // 2)*2 + max_shift = -1 + else: + if prev._open_borders[1]: + min_shift = 0 + max_shift = prev.W - 2 + else: + max_shift = -1 + min_shift = - (W - 2) + else: + # Last section was odd, incoming section is even. + if prev._open_borders[1]: + special_shifts = [0, prev.W - W] + min_shift = None + else: + min_shift = [1, - (W - 2)] + max_shift = [prev.W - 2, prev.W - W - 1] + + # We have gone over all possible situations, now just build the + # array of valid shifts. + valid_shifts = [*special_shifts] + if isinstance(min_shift, int): + valid_shifts.extend(np.arange(min_shift, max_shift + 1, 2)) + elif isinstance(min_shift, list): + for (m, mx) in zip(min_shift, max_shift): + valid_shifts.extend(np.arange(m, mx + 1, 2)) + + # Apply offset on shifts based on the actual alignment requested + # for the sections. + shift_offset = 0 + if align[0] == "t": + shift_offset = W_diff + elif align[0] == "c": + shift_offset = - self._offset_from_center("b", prev) + valid_shifts = np.array(valid_shifts) + shift_offset + + # Finally, check if the provided shift value is valid or not. + valid_shifts = np.sort(valid_shifts) + if self.shift_quantum: + n_valid_shifts = len(valid_shifts) + # Find out if it is possible to perfectly align. + if np.any(valid_shifts == 0): + aligned_shift = np.where(valid_shifts == 0)[0][0] + else: + # If not, we have to find the smallest shift. + # What flip does is prioritize upwards shifts. + # That is, if both "-1" and "1" shifts are valid, + # "1" will be picked as the reference. + aligned_shift = n_valid_shifts - 1 - np.argmin(np.abs(np.flip(valid_shifts))) + + # Calculate which index we really need to retrieve + corrected_shift = aligned_shift + shift + + if corrected_shift < 0 or corrected_shift >= n_valid_shifts: + self._junction_error(prev, f"LONE ATOMS: Shift must be between {-aligned_shift}" + f" and {n_valid_shifts - aligned_shift - 1}, but {shift} was provided.", + self.on_lone_atom + ) + + # And finally get the shift value + shift = valid_shifts[corrected_shift] + else: + if shift not in valid_shifts: + self._junction_error(prev, f"LONE ATOMS: Shift must be one of {valid_shifts}" + f" but {shift} was provided.", self.on_lone_atom + ) + + return shift + + +@set_module("sisl.geom") +def heteroribbon(sections, section_cls=_heteroribbon_section, **kwargs): + """Build a nanoribbon consisting of several nanoribbons of different widths. + + This function basically uses `composite_geometry`, but defaulting to the usage + of `_heteroribbon_section` as the section class. + + See `heteroribbon_section` and `composite_geometry` for arguments. + + Returns + ------- + sisl.Geometry: + The final structure of the heteroribbon. + + Notes + ----- + It only works for armchair ribbons for now. + + Examples + -------- + >>> # A simple 7-11-AGNR with the sections aligned on the center + >>> heteroribbon([(7, 2), (11, 2)], bond=1.42, atoms="C") + >>> # The same AGNR but shifted up + >>> heteroribbon([(7, 2), (11, 2, 1)], bond=1.42, atoms="C") + >>> # And down + >>> heteroribbon([(7, 2), (11, 2, -1)], bond=1.42, atoms="C") + >>> # The same AGNR but with a bigger 11 section and a 9-atom bridge + >>> heteroribbon([(7, 1), (9,1), (11, 4), (9,1), (7,1)], bond=1.42, atoms="C") + >>> # One that you have probably never seen before + >>> heteroribbon([(7, 1j), (10, 2), (9, 1), (8, 2j, -1)], bond=1.42, atoms="C") + + See also + -------- + composite_geometry: Underlying method used to build the heteroribbon. + _heteroribbon_section: The class that describes each section. + nanoribbon : method used to create the segments + graphene_heteroribbon: same as this function, but with defaults for graphene GNR's + """ + return composite_geometry(sections, section_cls=section_cls, **kwargs) + + +@set_module("sisl.geom") +def graphene_heteroribbon(sections, section_cls=_heteroribbon_section, bond=1.42, atoms=None, **kwargs): + """Build a graphene nanoribbon consisting of several nanoribbons of different widths + + Please see `heteroribbon` for arguments, the only difference is that the `bond` and `atoms` + arguments default to ``bond=1.42`` and ``Atoms(Z=6, R=bond*1.01)``, respectively. + + See also + ---------- + `heteroribbon` : for argument details and how it behaves + """ + if atoms is None: + atoms = Atom(Z=6, R=bond * 1.01) + return composite_geometry(sections, section_cls=section_cls, bond=bond, atoms=atoms, **kwargs) diff --git a/src/sisl/geom/tests/test_geom.py b/src/sisl/geom/tests/test_geom.py index 119a6bd1fa..9f897fffca 100644 --- a/src/sisl/geom/tests/test_geom.py +++ b/src/sisl/geom/tests/test_geom.py @@ -115,6 +115,64 @@ def test_zgnr(): assert is_right_handed(a) +def test_heteroribbon(): + """Runs the heteroribbon builder for all possible combinations of + widths and asserts that they are always properly aligned. + """ + # Build combinations + combinations = itertools.product([7, 8, 9, 10, 11], [7, 8, 9, 10, 11]) + L = itertools.repeat(2) + + for Ws in combinations: + geom = heteroribbon(zip(Ws, L), bond=1.42, atoms=Atom(6, 1.43), align="auto", shift_quantum=True) + + # Assert no dangling bonds. + assert len(geom.asc2uc({"neighbours": 1})) == 0 + + +def test_graphene_heteroribbon(): + a = graphene_heteroribbon([(7, 2), (9, 2)]) + + +def test_graphene_heteroribbon_errors(): + + + # 7-open with 9 can only be perfectly aligned. + graphene_heteroribbon([(7,1), (9,1)], align="center", on_lone_atom="raise") + with pytest.raises(ValueError): + graphene_heteroribbon([(7,1), (9,1,-1)], align="center", on_lone_atom="raise") + + grap_heteroribbon = partial( + graphene_heteroribbon, align="auto", shift_quantum=True + ) + + # Odd section with open end + with pytest.raises(ValueError): + grap_heteroribbon([(7, 3), (5, 2)]) + + # Shift limits are imposed correctly + # In this case -2 < shift < 1 + grap_heteroribbon([(7, 3), (11, 2, 0)]) + grap_heteroribbon([(7, 3), (11, 2, -1)]) + with pytest.raises(ValueError): + grap_heteroribbon([(7, 3), (11, 2, 1)]) + with pytest.raises(ValueError): + grap_heteroribbon([(7, 3), (11, 2, -2)]) + + # Periodic boundary conditions work properly + # grap_heteroribbon([[10, 2], [8, 1, 0]], pbc=False) + # with pytest.raises(ValueError): + # grap_heteroribbon([[10, 2], [8, 1, 0]], pbc=True) + + # Even ribbons should only be shifted towards the center + grap_heteroribbon([(10, 2), (8, 2, -1)]) + with pytest.raises(ValueError): + grap_heteroribbon([(10, 2), (8, 2, 1)]) + grap_heteroribbon([(10, 1), (8, 2, 1)],) #pbc=False) + with pytest.raises(ValueError): + grap_heteroribbon([(10, 1), (8, 2, -1)],) #pbc=False) + + def test_fcc_slab(): for o in [True, False]: fcc_slab(alat=4.08, atoms='Au', miller=(1, 0, 0), orthogonal=o) From 201c95308e21b54452ef9cc6b8c3c7d161e2ebb9 Mon Sep 17 00:00:00 2001 From: Pol Febrer Date: Wed, 26 Jan 2022 18:20:42 +0100 Subject: [PATCH 2/8] bug: less buggy way of shifting the cell --- src/sisl/geom/nanoribbon.py | 4 +++- src/sisl/geom/tests/test_geom.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/sisl/geom/nanoribbon.py b/src/sisl/geom/nanoribbon.py index 664243863d..bc03fd2b7b 100644 --- a/src/sisl/geom/nanoribbon.py +++ b/src/sisl/geom/nanoribbon.py @@ -245,7 +245,9 @@ def _shift_unit_cell(self, geometry): It does so by shifting half unit cell. This must be done before any tiling of the geometry. """ - geometry = geometry.move(geometry.cell[self.long_ax] / 2) + move = np.array([0., 0., 0.]) + move[self.long_ax] = -geometry.xyz[self.W, self.long_ax] + geometry = geometry.move(move) geometry.xyz = (geometry.fxyz % 1).dot(geometry.cell) return geometry diff --git a/src/sisl/geom/tests/test_geom.py b/src/sisl/geom/tests/test_geom.py index 9f897fffca..40e1d73de3 100644 --- a/src/sisl/geom/tests/test_geom.py +++ b/src/sisl/geom/tests/test_geom.py @@ -115,6 +115,16 @@ def test_zgnr(): assert is_right_handed(a) +@pytest.mark.parametrize( + "W, invert_first", itertools.product(range(3, 20), [True, False]), +) +def test_heteroribbon_one_unit(W, invert_first): + # Check that all ribbon widths are properly cut into one unit + geometry = heteroribbon([(W, 1)], bond=1.42, atoms=Atom(6, 1.43), invert_first=invert_first) + + assert geometry.na == W + + def test_heteroribbon(): """Runs the heteroribbon builder for all possible combinations of widths and asserts that they are always properly aligned. @@ -135,8 +145,6 @@ def test_graphene_heteroribbon(): def test_graphene_heteroribbon_errors(): - - # 7-open with 9 can only be perfectly aligned. graphene_heteroribbon([(7,1), (9,1)], align="center", on_lone_atom="raise") with pytest.raises(ValueError): From 331d3e4523e1cf9511a1db8e761ff611028e4a70 Mon Sep 17 00:00:00 2001 From: Pol Febrer Date: Wed, 26 Jan 2022 21:33:15 +0100 Subject: [PATCH 3/8] bug: corrected some bugs on valid shifts --- src/sisl/geom/nanoribbon.py | 10 +++++----- src/sisl/geom/tests/test_geom.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/sisl/geom/nanoribbon.py b/src/sisl/geom/nanoribbon.py index bc03fd2b7b..8f3ffcc7f9 100644 --- a/src/sisl/geom/nanoribbon.py +++ b/src/sisl/geom/nanoribbon.py @@ -411,7 +411,7 @@ def _parse_shift(self, shift, prev, align): self._junction_error(prev, "LONE ATOMS: Previous odd section, which has an open end," " is wider than the incoming one. A wider odd section must always" " have a closed end. You can solve this by making the previous section" - " one unit smaller or larger (L = L +- 1).", "raise" + " one unit smaller or larger (L = L +- 1).", self.on_lone_atom ) # Get the difference in width between the previous and this ribbon section @@ -420,8 +420,8 @@ def _parse_shift(self, shift, prev, align): # on 1, 2, 3 or 4 atoms. After that, the cycle just repeats (e.g. 5 == 1, etc). diff_mod = W_diff % 4 - # Now, we need to calculate the offset that we have to apply to the incoming - # section depending on several factors. + # Calculate the shifts that are valid (don't leave atoms with less than 2 bonds) + # This depends on several factors. if diff_mod % 2 == 0 and W % 2 == 1: # Both sections are odd @@ -450,7 +450,7 @@ def _parse_shift(self, shift, prev, align): # Update the valid shift limits if the sections are aligned on any of the edges. shift_offset = self._offset_from_center(align, prev) - valid_shifts += shift_offset + valid_shifts -= shift_offset elif prev.W == W: valid_shifts = np.array([0]) else: @@ -474,7 +474,7 @@ def _parse_shift(self, shift, prev, align): shift_offset = self._offset_from_center(align, prev) # Apply the offsets and calculate the maximum and minimum shifts. - min_shift, max_shift = -shift_lim + shift_offset, shift_lim + shift_offset + min_shift, max_shift = -shift_lim - shift_offset, shift_lim - shift_offset valid_shifts = np.arange(min_shift, max_shift + 1, 2) else: diff --git a/src/sisl/geom/tests/test_geom.py b/src/sisl/geom/tests/test_geom.py index 40e1d73de3..a3f5b1bb0a 100644 --- a/src/sisl/geom/tests/test_geom.py +++ b/src/sisl/geom/tests/test_geom.py @@ -146,9 +146,18 @@ def test_graphene_heteroribbon(): def test_graphene_heteroribbon_errors(): # 7-open with 9 can only be perfectly aligned. - graphene_heteroribbon([(7,1), (9,1)], align="center", on_lone_atom="raise") + graphene_heteroribbon([(7, 1), (9, 1)], align="center", on_lone_atom="raise") with pytest.raises(ValueError): - graphene_heteroribbon([(7,1), (9,1,-1)], align="center", on_lone_atom="raise") + graphene_heteroribbon([(7, 1), (9, 1, -1)], align="center", on_lone_atom="raise") + # From the bottom + graphene_heteroribbon([(7, 1), (9, 1, -1)], align="bottom", on_lone_atom="raise") + with pytest.raises(ValueError): + graphene_heteroribbon([(7, 1), (9, 1, 0)], align="bottom", on_lone_atom="raise") + # And from the top + graphene_heteroribbon([(7, 1), (9, 1, 1)], align="top", on_lone_atom="raise") + with pytest.raises(ValueError): + graphene_heteroribbon([(7, 1), (9, 1, -1)], align="top", on_lone_atom="raise") + grap_heteroribbon = partial( graphene_heteroribbon, align="auto", shift_quantum=True From 076e18d6e6d2647dc0a48d09b8a1e4ae06e3f0f1 Mon Sep 17 00:00:00 2001 From: Nick Papior Date: Mon, 10 Jul 2023 11:18:11 +0200 Subject: [PATCH 4/8] fixed tests after rebasing Signed-off-by: Nick Papior --- src/sisl/geom/tests/test_geom.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sisl/geom/tests/test_geom.py b/src/sisl/geom/tests/test_geom.py index a3f5b1bb0a..d2708b9d3e 100644 --- a/src/sisl/geom/tests/test_geom.py +++ b/src/sisl/geom/tests/test_geom.py @@ -4,6 +4,11 @@ import numpy as np import pytest +from functools import partial +import itertools +import math as m +import numpy as np + from sisl import Atom, Lattice from sisl._math_small import cross3, dot3 from sisl.geom import * From 628cee41736f67a75f57339c9e682bfa9901656d Mon Sep 17 00:00:00 2001 From: Nick Papior Date: Mon, 10 Jul 2023 21:31:23 +0200 Subject: [PATCH 5/8] fixed tests and docs Signed-off-by: Nick Papior --- docs/api/default_geom.rst | 14 ++++++++++ src/sisl/geom/__init__.py | 1 + src/sisl/geom/{composite.py => _composite.py} | 26 ++++++++++++++----- src/sisl/geom/nanoribbon.py | 19 +++++++++----- src/sisl/geom/tests/test_geom.py | 18 ++++++------- 5 files changed, 56 insertions(+), 22 deletions(-) rename src/sisl/geom/{composite.py => _composite.py} (78%) diff --git a/docs/api/default_geom.rst b/docs/api/default_geom.rst index 34814da6eb..4067148eb5 100644 --- a/docs/api/default_geom.rst +++ b/docs/api/default_geom.rst @@ -14,6 +14,9 @@ geometries via a `pull request `_. All methods return a `Geometry` object. +Some of the geometries are created in section based geometries, such as `heteroribbon`. +This functionality is provided through the `composite_geometry` + Bulk ==== @@ -51,6 +54,8 @@ Surfaces (slabs) zgnr graphene_nanoribbon nanotube + heteroribbon + graphene_heteroribbon 2D materials @@ -62,3 +67,12 @@ Surfaces (slabs) honeycomb bilayer graphene + + +Helpers +======= + +.. autosummary:: + :toctree: generated/ + + composite_geometry diff --git a/src/sisl/geom/__init__.py b/src/sisl/geom/__init__.py index c04358d943..2dd2becd94 100644 --- a/src/sisl/geom/__init__.py +++ b/src/sisl/geom/__init__.py @@ -42,6 +42,7 @@ graphene """ +from ._composite import * from .basic import * from .bilayer import * from .category import * diff --git a/src/sisl/geom/composite.py b/src/sisl/geom/_composite.py similarity index 78% rename from src/sisl/geom/composite.py rename to src/sisl/geom/_composite.py index d55bdc2b80..3d2ddbdace 100644 --- a/src/sisl/geom/composite.py +++ b/src/sisl/geom/_composite.py @@ -1,11 +1,23 @@ from dataclasses import dataclass, copy, fields +from abc import abstractmethod -from sisl.messages import warn +from sisl.messages import warn, SislError + + +__all__ = ["composite_geometry"] @dataclass class _geom_section: + @abstractmethod + def build_section(self, geometry): + ... + + @abstractmethod + def add_section(self, geometry, geometry_addition): + ... + def _junction_error(self, prev, msg, what): """Helper function to raise an error if the junction is not valid. @@ -14,7 +26,7 @@ def _junction_error(self, prev, msg, what): """ msg = f"Error at junction between sections {prev} and {self}. {msg}" if what == "raise": - raise ValueError(msg) + raise SislError(msg) elif what == "warn": warn(msg) @@ -29,8 +41,8 @@ def composite_geometry(sections, section_cls=_geom_section, **kwargs): sections: array-like of (_geom_section or tuple or dict) A list of sections to be added to the ribbon. - Each section is either a `_geom_section` or something that will - be parsed to a `_geom_section`. + Each section is either a `composite_geometry.section` or something that will + be parsed to a `composite_geometry.section`. section_cls: class, optional The class to use for parsing sections. **kwargs: @@ -50,12 +62,12 @@ def conv(s): return copy.copy(s) - sections = [conv(section) for section in sections] - # Then loop through all the sections. geom = None prev = None for i, section in enumerate(sections): + section = conv(section) + new_addition = section.build_section(prev) if i == 0: @@ -66,3 +78,5 @@ def conv(s): prev = section return geom + +composite_geometry.section = _geom_section diff --git a/src/sisl/geom/nanoribbon.py b/src/sisl/geom/nanoribbon.py index 8f3ffcc7f9..f134d5d878 100644 --- a/src/sisl/geom/nanoribbon.py +++ b/src/sisl/geom/nanoribbon.py @@ -7,14 +7,14 @@ import numpy as np from sisl import Atom, geom -from .composite import _geom_section, composite_geometry +from ._composite import _geom_section, composite_geometry from sisl._internal import set_module from ._common import geometry_define_nsc __all__ = [ 'nanoribbon', 'graphene_nanoribbon', 'agnr', 'zgnr', - 'heteroribbon', 'graphene_heteroribbon', '_heteroribbon_section' + 'heteroribbon', 'graphene_heteroribbon', ] @@ -165,6 +165,7 @@ def zgnr(width, bond=1.42, atoms=None): """ return graphene_nanoribbon(width, bond, atoms, kind='zigzag') + @set_module("sisl.geom") @dataclass class _heteroribbon_section(_geom_section): @@ -582,14 +583,14 @@ def _parse_shift(self, shift, prev, align): def heteroribbon(sections, section_cls=_heteroribbon_section, **kwargs): """Build a nanoribbon consisting of several nanoribbons of different widths. - This function basically uses `composite_geometry`, but defaulting to the usage - of `_heteroribbon_section` as the section class. + This function uses `composite_geometry`, but defaulting to the usage + of `heteroribbon.section` as the section class. - See `heteroribbon_section` and `composite_geometry` for arguments. + See `heteroribbon.section` and `composite_geometry` for arguments. Returns ------- - sisl.Geometry: + Geometry: The final structure of the heteroribbon. Notes @@ -618,6 +619,8 @@ def heteroribbon(sections, section_cls=_heteroribbon_section, **kwargs): """ return composite_geometry(sections, section_cls=section_cls, **kwargs) +heteroribbon.section=_heteroribbon_section + @set_module("sisl.geom") def graphene_heteroribbon(sections, section_cls=_heteroribbon_section, bond=1.42, atoms=None, **kwargs): @@ -628,8 +631,10 @@ def graphene_heteroribbon(sections, section_cls=_heteroribbon_section, bond=1.42 See also ---------- - `heteroribbon` : for argument details and how it behaves + heteroribbon : for argument details and how it behaves """ if atoms is None: atoms = Atom(Z=6, R=bond * 1.01) return composite_geometry(sections, section_cls=section_cls, bond=bond, atoms=atoms, **kwargs) + +graphene_heteroribbon.section=_heteroribbon_section diff --git a/src/sisl/geom/tests/test_geom.py b/src/sisl/geom/tests/test_geom.py index d2708b9d3e..b743c87cc2 100644 --- a/src/sisl/geom/tests/test_geom.py +++ b/src/sisl/geom/tests/test_geom.py @@ -9,7 +9,7 @@ import math as m import numpy as np -from sisl import Atom, Lattice +from sisl import Atom, Lattice, SislError from sisl._math_small import cross3, dot3 from sisl.geom import * @@ -152,15 +152,15 @@ def test_graphene_heteroribbon(): def test_graphene_heteroribbon_errors(): # 7-open with 9 can only be perfectly aligned. graphene_heteroribbon([(7, 1), (9, 1)], align="center", on_lone_atom="raise") - with pytest.raises(ValueError): + with pytest.raises(SislError): graphene_heteroribbon([(7, 1), (9, 1, -1)], align="center", on_lone_atom="raise") # From the bottom graphene_heteroribbon([(7, 1), (9, 1, -1)], align="bottom", on_lone_atom="raise") - with pytest.raises(ValueError): + with pytest.raises(SislError): graphene_heteroribbon([(7, 1), (9, 1, 0)], align="bottom", on_lone_atom="raise") # And from the top graphene_heteroribbon([(7, 1), (9, 1, 1)], align="top", on_lone_atom="raise") - with pytest.raises(ValueError): + with pytest.raises(SislError): graphene_heteroribbon([(7, 1), (9, 1, -1)], align="top", on_lone_atom="raise") @@ -169,16 +169,16 @@ def test_graphene_heteroribbon_errors(): ) # Odd section with open end - with pytest.raises(ValueError): + with pytest.raises(SislError): grap_heteroribbon([(7, 3), (5, 2)]) # Shift limits are imposed correctly # In this case -2 < shift < 1 grap_heteroribbon([(7, 3), (11, 2, 0)]) grap_heteroribbon([(7, 3), (11, 2, -1)]) - with pytest.raises(ValueError): + with pytest.raises(SislError): grap_heteroribbon([(7, 3), (11, 2, 1)]) - with pytest.raises(ValueError): + with pytest.raises(SislError): grap_heteroribbon([(7, 3), (11, 2, -2)]) # Periodic boundary conditions work properly @@ -188,10 +188,10 @@ def test_graphene_heteroribbon_errors(): # Even ribbons should only be shifted towards the center grap_heteroribbon([(10, 2), (8, 2, -1)]) - with pytest.raises(ValueError): + with pytest.raises(SislError): grap_heteroribbon([(10, 2), (8, 2, 1)]) grap_heteroribbon([(10, 1), (8, 2, 1)],) #pbc=False) - with pytest.raises(ValueError): + with pytest.raises(SislError): grap_heteroribbon([(10, 1), (8, 2, -1)],) #pbc=False) From 22c51e16a02153e05e5f1b4c2a0a36b7bbd70427 Mon Sep 17 00:00:00 2001 From: Nick Papior Date: Mon, 10 Jul 2023 22:23:02 +0200 Subject: [PATCH 6/8] rebase isort fixes Signed-off-by: Nick Papior --- src/sisl/geom/_composite.py | 5 ++--- src/sisl/geom/nanoribbon.py | 2 +- src/sisl/geom/tests/test_geom.py | 7 +++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/sisl/geom/_composite.py b/src/sisl/geom/_composite.py index 3d2ddbdace..76162d7458 100644 --- a/src/sisl/geom/_composite.py +++ b/src/sisl/geom/_composite.py @@ -1,8 +1,7 @@ -from dataclasses import dataclass, copy, fields from abc import abstractmethod +from dataclasses import copy, dataclass, fields -from sisl.messages import warn, SislError - +from sisl.messages import SislError, warn __all__ = ["composite_geometry"] diff --git a/src/sisl/geom/nanoribbon.py b/src/sisl/geom/nanoribbon.py index f134d5d878..9d894eb7a1 100644 --- a/src/sisl/geom/nanoribbon.py +++ b/src/sisl/geom/nanoribbon.py @@ -7,10 +7,10 @@ import numpy as np from sisl import Atom, geom -from ._composite import _geom_section, composite_geometry from sisl._internal import set_module from ._common import geometry_define_nsc +from ._composite import _geom_section, composite_geometry __all__ = [ 'nanoribbon', 'graphene_nanoribbon', 'agnr', 'zgnr', diff --git a/src/sisl/geom/tests/test_geom.py b/src/sisl/geom/tests/test_geom.py index b743c87cc2..0ba7806829 100644 --- a/src/sisl/geom/tests/test_geom.py +++ b/src/sisl/geom/tests/test_geom.py @@ -1,13 +1,12 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. -import numpy as np -import pytest - -from functools import partial import itertools import math as m +from functools import partial + import numpy as np +import pytest from sisl import Atom, Lattice, SislError from sisl._math_small import cross3, dot3 From 1915527b8f530682679036069cd32d19c946c27a Mon Sep 17 00:00:00 2001 From: Nick Papior Date: Mon, 10 Jul 2023 22:29:02 +0200 Subject: [PATCH 7/8] renamed section class names Now they are exposed Signed-off-by: Nick Papior --- src/sisl/geom/_composite.py | 9 +++++---- src/sisl/geom/nanoribbon.py | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/sisl/geom/_composite.py b/src/sisl/geom/_composite.py index 76162d7458..efe2585c1c 100644 --- a/src/sisl/geom/_composite.py +++ b/src/sisl/geom/_composite.py @@ -3,11 +3,11 @@ from sisl.messages import SislError, warn -__all__ = ["composite_geometry"] +__all__ = ["composite_geometry", "CompositeGeometrySection"] @dataclass -class _geom_section: +class CompositeGeometrySection: @abstractmethod def build_section(self, geometry): @@ -30,7 +30,7 @@ def _junction_error(self, prev, msg, what): warn(msg) -def composite_geometry(sections, section_cls=_geom_section, **kwargs): +def composite_geometry(sections, section_cls, **kwargs): """Creates a composite geometry from a list of sections. The sections are added one after another in the provided order. @@ -78,4 +78,5 @@ def conv(s): return geom -composite_geometry.section = _geom_section + +composite_geometry.section = CompositeGeometrySection diff --git a/src/sisl/geom/nanoribbon.py b/src/sisl/geom/nanoribbon.py index 9d894eb7a1..e947eecf20 100644 --- a/src/sisl/geom/nanoribbon.py +++ b/src/sisl/geom/nanoribbon.py @@ -10,7 +10,7 @@ from sisl._internal import set_module from ._common import geometry_define_nsc -from ._composite import _geom_section, composite_geometry +from ._composite import CompositeGeometrySection, composite_geometry __all__ = [ 'nanoribbon', 'graphene_nanoribbon', 'agnr', 'zgnr', @@ -168,7 +168,7 @@ def zgnr(width, bond=1.42, atoms=None): @set_module("sisl.geom") @dataclass -class _heteroribbon_section(_geom_section): +class _heteroribbon_section(CompositeGeometrySection): """ Parameters ---------- @@ -619,7 +619,7 @@ def heteroribbon(sections, section_cls=_heteroribbon_section, **kwargs): """ return composite_geometry(sections, section_cls=section_cls, **kwargs) -heteroribbon.section=_heteroribbon_section +heteroribbon.section = _heteroribbon_section @set_module("sisl.geom") @@ -629,12 +629,12 @@ def graphene_heteroribbon(sections, section_cls=_heteroribbon_section, bond=1.42 Please see `heteroribbon` for arguments, the only difference is that the `bond` and `atoms` arguments default to ``bond=1.42`` and ``Atoms(Z=6, R=bond*1.01)``, respectively. - See also - ---------- + See Also + -------- heteroribbon : for argument details and how it behaves """ if atoms is None: atoms = Atom(Z=6, R=bond * 1.01) return composite_geometry(sections, section_cls=section_cls, bond=bond, atoms=atoms, **kwargs) -graphene_heteroribbon.section=_heteroribbon_section +graphene_heteroribbon.section = _heteroribbon_section From eefa952144b686f195bee1c2a4b14244cfe270d9 Mon Sep 17 00:00:00 2001 From: Nick Papior Date: Sun, 16 Jul 2023 22:01:00 +0200 Subject: [PATCH 8/8] added CompositeGeometrySection to docs Signed-off-by: Nick Papior --- docs/api/default_geom.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api/default_geom.rst b/docs/api/default_geom.rst index 4067148eb5..061b7987e9 100644 --- a/docs/api/default_geom.rst +++ b/docs/api/default_geom.rst @@ -76,3 +76,5 @@ Helpers :toctree: generated/ composite_geometry + CompositeGeometrySection +