pyatomsk 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyatomsk-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodolfo Herrera Hernandez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyatomsk
3
+ Version: 0.3.0
4
+ Requires-Python: >=3.10
5
+ License-File: LICENSE
6
+ Requires-Dist: voltsdk>=3.0.0
7
+ Dynamic: license-file
8
+ Dynamic: requires-dist
9
+ Dynamic: requires-python
@@ -0,0 +1,149 @@
1
+ # Python wrapper for Atomsk
2
+
3
+ ## Install
4
+
5
+ ```bash
6
+ pip install pyatomsk
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ from pathlib import Path
13
+ from pyatomsk import AtomicStructure, CubicLattices, DislocationBuilder, DislocationLoop, PluginHub, view
14
+
15
+ REGISTRY = 'https://raw.githubusercontent.com/VoltLabs-Research/Volt/main/server/static/plugin-registry'
16
+ OUT = Path('output/fcc-dxa')
17
+
18
+ hub = PluginHub(url=REGISTRY, default_publisher='voltlabs')
19
+ ptm = hub.get('polyhedral-template-matching')
20
+ dxa = hub.get('opendxa')
21
+
22
+ # 1. Build an FCC Al slab with a prismatic dislocation loop.
23
+ structure = AtomicStructure(
24
+ lattice=CubicLattices.FCC,
25
+ lattice_params=[4.06],
26
+ species=['Al'],
27
+ orient=['[110]', '[1-12]', '[-111]'],
28
+ duplicate=[60, 40, 20],
29
+ )
30
+ loop = DislocationLoop(x='0.501*box', y='0.501*box', z='0.501*box',
31
+ normal='Z', radius=30, burgers=[2.860954, 0, 0], poisson=0.33)
32
+ builder = DislocationBuilder(atomic_structure=structure, output_file='Al_loop.lmp', dislocations=[loop])
33
+ lmp = builder.run() # downloads Atomsk if needed, returns the output Path
34
+
35
+ # 2. Run the analysis plugins locally.
36
+ ptm_run = ptm(lmp, output_dir=OUT, crystal_structure='fcc', rmsd=0.1)
37
+ dxa_run = dxa(ptm_run, output_dir=OUT, reference_topology='fcc', export_as='json')
38
+
39
+ # 3. Inspect results as a DataFrame, then view them in VOLT.
40
+ print(dxa_run['dislocations.json'].df())
41
+ view(ptm_run['atoms.msgpack'], output_path=OUT / 'ptm_atoms.glb')
42
+ view(dxa_run['dislocations.json'])
43
+ ```
44
+
45
+
46
+ ## Building structures
47
+
48
+ `AtomicStructure` maps directly onto `atomsk --create`. You can inspect the command
49
+ before running it:
50
+
51
+ ```python
52
+ from pyatomsk import AtomicStructure, CubicLattices
53
+
54
+ s = AtomicStructure(
55
+ lattice=CubicLattices.FCC,
56
+ lattice_params=[4.05], # a (and c for tetragonal/hexagonal)
57
+ species=['Al'],
58
+ duplicate=[10, 10, 10],
59
+ export_filename='al.xsf',
60
+ formats=['xsf'],
61
+ )
62
+ s.to_command() # 'atomsk --create fcc 4.05 Al -duplicate 10 10 10 al.xsf xsf'
63
+ s.run() # executes it, returns Path('al.xsf')
64
+ ```
65
+
66
+ Lattice enums: `CubicLattices` (FCC, BCC, SC, diamond, …), `TetragonalLattices`
67
+ (BCT, FCT, ST, L1_0) and `HexagonalLattices` (HCP, graphite, …).
68
+
69
+ For non-standard cells, `CustomAtomicStructure` writes an XSF seed from an explicit
70
+ `cell` + fractional `basis` and feeds it to Atomsk.
71
+
72
+ ## Adding dislocations
73
+
74
+ `DislocationBuilder` prepends a structure command and appends one or more
75
+ `-dislocation` fragments:
76
+
77
+ ```python
78
+ from pyatomsk import AtomicStructure, TetragonalLattices, Dislocation, DislocationCharacter, DislocationBuilder
79
+
80
+ structure = AtomicStructure(lattice=TetragonalLattices.BCT, lattice_params=[2.86, 2.95],
81
+ species=['Fe'], duplicate=[32, 32, 24])
82
+ edge = Dislocation(character=DislocationCharacter.EDGE_ADD, p1='0.501*box', p2='0.501*box',
83
+ line='z', plane='y', burgers=[2.86], poisson=0.29)
84
+
85
+ builder = DislocationBuilder(atomic_structure=structure, output_file='bct.lmp', dislocations=[edge])
86
+ builder.run()
87
+ ```
88
+
89
+ Use `DislocationLoop` for prismatic loops (`-dislocation loop …`). Both are pure
90
+ command fragments; only `DislocationBuilder` (and the structures) are runnable.
91
+
92
+
93
+ ## Running VOLT plugins
94
+
95
+ Plugins are fetched from a VOLT plugin registry and executed as local subprocesses by
96
+ voltsdk. A `PluginRun` exposes its outputs as artifacts you
97
+ can look up by name (fuzzy: `.json` ⇄ `.msgpack`, prefixes stripped):
98
+
99
+ ```python
100
+ run = ptm(lmp, output_dir='out', crystal_structure='fcc', rmsd=0.1)
101
+ run['annotated.dump'] # PluginArtifact (os.PathLike)
102
+ run['clusters.table'].df() # pandas DataFrame
103
+ run['atoms.msgpack'].json() # parsed payload
104
+ ```
105
+
106
+ OpenDXA auto-wires from a previous run: `dxa(ptm_run, reference_topology='fcc')` pulls the
107
+ annotated dump and cluster tables automatically. After a `pattern-structure-matching` run,
108
+ `dxa(psm_run)` also infers `reference_topology` and `lattice_dir` from the run's lattices.
109
+
110
+ ## Viewing in VOLT
111
+
112
+ `view()` converts an artifact (or a path / list of them) to GLB, the exporter is detected
113
+ from the payload, and opens it in the VOLT canvas via a local server. GLB files pass
114
+ through untouched.
115
+
116
+ ```python
117
+ view(dxa_run['dislocations.json']) # opens in VOLT
118
+ view(ptm_run['atoms.msgpack'], output_path='atoms.glb') # also keep the GLB
119
+ view(dxa_run['defect_mesh.json'], exporter='MeshExporter') # force a specific layer
120
+ ```
121
+
122
+ `view()` returns the viewer URL. Equivalent low-level voltsdk calls are re-exported too:
123
+ `PluginArtifact.glb()`, `PluginArtifact.open_in_volt()`, and `open_in_volt(path)`.
124
+
125
+ ## Configuration
126
+
127
+ | Variable | Purpose |
128
+ |---|---|
129
+ | `ATOMSK_BIN` | Path to an existing Atomsk executable (skips auto-download). |
130
+ | `XDG_CACHE_HOME` | Cache root; Atomsk under `<cache>/pyatomsk`, plugins under `<cache>/volt`. |
131
+ | `VOLT_PLUGIN_REGISTRY` | Override the plugin registry URL. |
132
+ | `VOLT_CACHE_DIR` | Override the voltsdk (plugin) cache directory. |
133
+ | `VOLT_APP_URL` | VOLT app URL used by the viewer. |
134
+
135
+ ## API reference
136
+
137
+ | Symbol | Description |
138
+ |---|---|
139
+ | `AtomicStructure`, `CustomAtomicStructure` | Build a crystal via `atomsk --create` / an XSF seed. |
140
+ | `CubicLattices`, `TetragonalLattices`, `HexagonalLattices` | Lattice type enums. |
141
+ | `Dislocation`, `DislocationLoop` | `-dislocation` command fragments. |
142
+ | `DislocationBuilder` | Combine a structure with one or more dislocations. |
143
+ | `AtomskCommand` | Base class: `.argv()`, `.to_command()`, `.run() -> Path`. |
144
+ | `view(source, *, output_path=None, …)` | Open an artifact/GLB/list in the VOLT canvas. |
145
+ | `PluginHub`, `Plugin`, `PluginRun`, `PluginArtifact`, `Lattice`, `SpatialAssembler`, `open_in_volt` | Re-exported from voltsdk. |
146
+
147
+ ## License
148
+
149
+ See the repository for license details.
@@ -0,0 +1,58 @@
1
+ """Build Atomsk structures/dislocations, run plugins locally, and view results in VOLT.
2
+
3
+ Plugin compute runs on your machine via :mod:`voltsdk`; visualization is delegated to the
4
+ VOLT canvas (``view`` / ``open_in_volt``). pyatomsk's own surface is just the Atomsk command
5
+ builders plus a thin viewer helper.
6
+ """
7
+
8
+ from voltsdk import (
9
+ Lattice,
10
+ Plugin,
11
+ PluginArtifact,
12
+ PluginError,
13
+ PluginHub,
14
+ PluginRun,
15
+ SpatialAssembler,
16
+ open_in_volt,
17
+ )
18
+
19
+ from pyatomsk.commands import AtomskCommand
20
+ from pyatomsk.dislocations import (
21
+ Dislocation,
22
+ DislocationBuilder,
23
+ DislocationCharacter,
24
+ DislocationLoop,
25
+ )
26
+ from pyatomsk.structures import (
27
+ AtomicStructure,
28
+ CubicLattices,
29
+ CustomAtomicStructure,
30
+ HexagonalLattices,
31
+ TetragonalLattices,
32
+ )
33
+ from pyatomsk.view import view
34
+
35
+ __all__ = [
36
+ # voltsdk re-exports (local compute, VOLT viewing)
37
+ 'PluginHub',
38
+ 'Plugin',
39
+ 'PluginArtifact',
40
+ 'PluginRun',
41
+ 'PluginError',
42
+ 'Lattice',
43
+ 'SpatialAssembler',
44
+ 'open_in_volt',
45
+ # Atomsk command builders
46
+ 'AtomskCommand',
47
+ 'AtomicStructure',
48
+ 'CustomAtomicStructure',
49
+ 'CubicLattices',
50
+ 'TetragonalLattices',
51
+ 'HexagonalLattices',
52
+ 'Dislocation',
53
+ 'DislocationCharacter',
54
+ 'DislocationLoop',
55
+ 'DislocationBuilder',
56
+ # thin viewer helper
57
+ 'view',
58
+ ]
@@ -0,0 +1,93 @@
1
+ """Resolve the Atomsk executable, downloading the official binary if needed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import tarfile
9
+ import urllib.request
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+ ATOMSK_VERSION = 'b0.13.1'
14
+ _BASE_URL = 'https://atomsk.univ-lille.fr/code'
15
+
16
+ # (system, machine) -> (archive filename, executable name)
17
+ _BINARIES = {
18
+ ('linux', 'x86_64'): (f'atomsk_{ATOMSK_VERSION}_Linux-amd64.tar.gz', 'atomsk'),
19
+ ('linux', 'amd64'): (f'atomsk_{ATOMSK_VERSION}_Linux-amd64.tar.gz', 'atomsk'),
20
+ ('linux', 'i686'): (f'atomsk_{ATOMSK_VERSION}_Linux-i686.tar.gz', 'atomsk'),
21
+ ('linux', 'i386'): (f'atomsk_{ATOMSK_VERSION}_Linux-i686.tar.gz', 'atomsk'),
22
+ ('windows', 'amd64'): (f'atomsk_{ATOMSK_VERSION}_Windows.zip', 'atomsk.exe'),
23
+ ('windows', 'x86_64'): (f'atomsk_{ATOMSK_VERSION}_Windows.zip', 'atomsk.exe'),
24
+ }
25
+
26
+
27
+ def _cache_dir() -> Path:
28
+ base = os.environ.get('XDG_CACHE_HOME') or str(Path.home() / '.cache')
29
+ return Path(base) / 'pyatomsk'
30
+
31
+
32
+ def _extract(archive: Path, target: Path) -> None:
33
+ if archive.name.endswith('.tar.gz'):
34
+ with tarfile.open(archive, 'r:gz') as tar:
35
+ tar.extractall(target)
36
+ elif archive.suffix == '.zip':
37
+ with zipfile.ZipFile(archive) as zf:
38
+ zf.extractall(target)
39
+ else:
40
+ raise ValueError(f'Unsupported Atomsk archive: {archive.name}')
41
+
42
+
43
+ def ensure_atomsk(*, version: str = ATOMSK_VERSION, force: bool = False) -> Path:
44
+ """Return the Atomsk executable for this OS, downloading and caching it if needed.
45
+
46
+ Resolution order: ``$ATOMSK_BIN`` -> ``atomsk`` on PATH -> cached download ->
47
+ download the official binary. Only Linux and Windows on x86 have official static
48
+ binaries; elsewhere (macOS, ARM) install Atomsk yourself (e.g.
49
+ ``conda install -c conda-forge atomsk``) and point ``ATOMSK_BIN`` at it or add it
50
+ to PATH.
51
+ """
52
+ override = os.environ.get('ATOMSK_BIN')
53
+ if override:
54
+ return Path(override).expanduser()
55
+
56
+ if not force:
57
+ on_path = shutil.which('atomsk')
58
+ if on_path:
59
+ return Path(on_path)
60
+
61
+ system, machine = platform.system().lower(), platform.machine().lower()
62
+ entry = _BINARIES.get((system, machine))
63
+ if entry is None:
64
+ raise RuntimeError(
65
+ f'No prebuilt Atomsk binary for {system}-{machine}. Install it '
66
+ '(e.g. `conda install -c conda-forge atomsk`), add it to PATH, or set '
67
+ 'ATOMSK_BIN to its location. See https://atomsk.univ-lille.fr/dl.php'
68
+ )
69
+
70
+ filename, exe = entry
71
+ install_dir = _cache_dir() / 'atomsk' / version / f'{system}-{machine}'
72
+ if install_dir.is_dir() and not force:
73
+ cached = next(install_dir.rglob(exe), None)
74
+ if cached is not None:
75
+ return cached
76
+
77
+ downloads = _cache_dir() / 'downloads'
78
+ downloads.mkdir(parents=True, exist_ok=True)
79
+ archive = downloads / filename
80
+ if not archive.is_file() or force:
81
+ with urllib.request.urlopen(f'{_BASE_URL}/{filename}') as response, archive.open('wb') as out:
82
+ shutil.copyfileobj(response, out)
83
+
84
+ if install_dir.exists():
85
+ shutil.rmtree(install_dir)
86
+ install_dir.mkdir(parents=True, exist_ok=True)
87
+ _extract(archive, install_dir)
88
+
89
+ binary = next(install_dir.rglob(exe), None)
90
+ if binary is None:
91
+ raise RuntimeError(f'{exe!r} not found inside the downloaded Atomsk archive {filename!r}.')
92
+ binary.chmod(binary.stat().st_mode | 0o111)
93
+ return binary
@@ -0,0 +1,51 @@
1
+ import shlex
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+
7
+ def _prepare_output_path(path: str | Path) -> Path:
8
+ target = Path(path).expanduser()
9
+ target.parent.mkdir(parents=True, exist_ok=True)
10
+ if target.exists():
11
+ if target.is_dir():
12
+ raise IsADirectoryError(f'Expected a file output path, got directory: {target}')
13
+ target.unlink()
14
+ return target
15
+
16
+
17
+ class AtomskCommand:
18
+ """Base for objects that generate (and run) an ``atomsk`` command."""
19
+
20
+ def argv(self) -> list[str]:
21
+ raise NotImplementedError
22
+
23
+ def to_command(self) -> str:
24
+ return shlex.join(self.argv())
25
+
26
+ def prepare_run(self) -> None:
27
+ """Hook for filesystem prep (seed files, output paths). Default: no-op."""
28
+
29
+ def output_path(self) -> Path | None:
30
+ """Path the command writes, or ``None`` if it only prints to stdout."""
31
+ return None
32
+
33
+ def run(
34
+ self,
35
+ *,
36
+ atomsk_output: bool = False,
37
+ check: bool = True,
38
+ text: bool = True,
39
+ **kwargs: Any,
40
+ ) -> Path | None:
41
+ from pyatomsk.atomsk import ensure_atomsk
42
+
43
+ run_kwargs = dict(kwargs)
44
+ if not atomsk_output:
45
+ run_kwargs.setdefault('stdout', subprocess.PIPE)
46
+ run_kwargs.setdefault('stderr', subprocess.PIPE)
47
+ self.prepare_run()
48
+ argv = self.argv()
49
+ argv[0] = str(ensure_atomsk())
50
+ subprocess.run(argv, check=check, text=text, **run_kwargs)
51
+ return self.output_path()
@@ -0,0 +1,117 @@
1
+ import shlex
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import Sequence, Union
6
+
7
+ from pyatomsk.commands import AtomskCommand, _prepare_output_path
8
+
9
+ Coord = Union[int, float, str]
10
+ Burgers = Union[float, Sequence[float]]
11
+
12
+
13
+ class DislocationCharacter(Enum):
14
+ EDGE = 'edge'
15
+ EDGE_ADD = 'edge_add'
16
+ EDGE_RM = 'edge_rm'
17
+ SCREW = 'screw'
18
+ MIXED = 'mixed'
19
+ LOOP = 'loop'
20
+
21
+
22
+ def _burgers_args(burgers: Burgers) -> list[Coord]:
23
+ if isinstance(burgers, Sequence) and not isinstance(burgers, (str, bytes)):
24
+ return list(burgers)
25
+ return [burgers]
26
+
27
+
28
+ class DislocationSpec:
29
+ """An ``-dislocation ...`` argv fragment (not a standalone command)."""
30
+
31
+ def argv(self) -> list[str]:
32
+ raise NotImplementedError
33
+
34
+ def to_command(self) -> str:
35
+ return shlex.join(self.argv())
36
+
37
+
38
+ @dataclass
39
+ class Dislocation(DislocationSpec):
40
+ character: DislocationCharacter
41
+ p1: Coord
42
+ p2: Coord
43
+ line: str
44
+ plane: str
45
+ burgers: Burgers
46
+ poisson: float | None = None
47
+
48
+ def argv(self) -> list[str]:
49
+ args = [
50
+ '-dislocation',
51
+ self.p1,
52
+ self.p2,
53
+ self.character.value,
54
+ self.line,
55
+ self.plane,
56
+ *_burgers_args(self.burgers),
57
+ ]
58
+ if self.poisson is not None:
59
+ args.append(self.poisson)
60
+ return [str(arg) for arg in args]
61
+
62
+
63
+ @dataclass
64
+ class DislocationLoop(DislocationSpec):
65
+ x: Coord
66
+ y: Coord
67
+ z: Coord
68
+ normal: str
69
+ radius: float
70
+ burgers: Sequence[float]
71
+ poisson: float
72
+
73
+ def argv(self) -> list[str]:
74
+ args = [
75
+ '-dislocation',
76
+ 'loop',
77
+ self.x,
78
+ self.y,
79
+ self.z,
80
+ self.normal,
81
+ self.radius,
82
+ *self.burgers,
83
+ self.poisson,
84
+ ]
85
+ return [str(arg) for arg in args]
86
+
87
+
88
+ @dataclass
89
+ class DislocationBuilder(AtomskCommand):
90
+ atomic_structure: AtomskCommand
91
+ output_file: str | None = None
92
+ formats: Sequence[str] = ()
93
+ dislocations: list[DislocationSpec] = field(default_factory=list)
94
+ options: list[str] = field(default_factory=list)
95
+
96
+ def argv(self) -> list[str]:
97
+ command = list(self.atomic_structure.argv(include_export=self.output_file is None))
98
+
99
+ for dislocation in self.dislocations:
100
+ command.extend(dislocation.argv())
101
+
102
+ if self.options:
103
+ command.extend(self.options)
104
+
105
+ if self.output_file:
106
+ command.append(self.output_file)
107
+ command.extend(self.formats)
108
+
109
+ return command
110
+
111
+ def output_path(self) -> Path | None:
112
+ return Path(self.output_file) if self.output_file else None
113
+
114
+ def prepare_run(self) -> None:
115
+ self.atomic_structure.prepare_run()
116
+ if self.output_file:
117
+ _prepare_output_path(self.output_file)
@@ -0,0 +1,130 @@
1
+ import shlex
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import Sequence, Union
6
+
7
+ from pyatomsk.commands import AtomskCommand, _prepare_output_path
8
+
9
+
10
+ class CubicLattices(Enum):
11
+ SC = 'sc'
12
+ BCC = 'bcc'
13
+ CsCl = 'CsCl'
14
+ FCC = 'fcc'
15
+ L12 = 'L12'
16
+ FLUORITE = 'fluorite'
17
+ DIAMOND = 'diamond'
18
+ ZINCBLENDE = 'zb'
19
+ ROCKSALT = 'rocksalt'
20
+ PEROVSKITE = 'per'
21
+ A15 = 'a15'
22
+ C15 = 'c15'
23
+
24
+
25
+ class TetragonalLattices(Enum):
26
+ ST = 'st'
27
+ BCT = 'bct'
28
+ FCT = 'fct'
29
+ L10 = 'L1_0'
30
+
31
+
32
+ class HexagonalLattices(Enum):
33
+ HCP = 'hcp'
34
+ WURTZITE = 'wz'
35
+ GRAPHITE = 'graphite'
36
+ BN = 'BN'
37
+ B12 = 'B12'
38
+ C14 = 'C14'
39
+ C36 = 'C36'
40
+
41
+
42
+ Lattices = Union[CubicLattices, TetragonalLattices, HexagonalLattices]
43
+ MillerIndex = Union[str, Sequence[int]]
44
+
45
+
46
+ def _miller(index: MillerIndex) -> str:
47
+ if isinstance(index, str):
48
+ return index
49
+ return '[' + ''.join(map(str, index)) + ']'
50
+
51
+
52
+ @dataclass
53
+ class AtomicStructure(AtomskCommand):
54
+ lattice: Lattices
55
+ lattice_params: Sequence[float]
56
+ species: Sequence[str]
57
+ orient: Sequence[MillerIndex] | None = None
58
+ duplicate: Sequence[int] | None = None
59
+ export_filename: str | None = None
60
+ formats: Sequence[str] = ()
61
+
62
+ def argv(self, *, include_export: bool = True) -> list[str]:
63
+ args = ['atomsk', '--create', self.lattice.value]
64
+ args.extend(map(str, self.lattice_params))
65
+ args.extend(self.species)
66
+
67
+ if self.orient is not None:
68
+ args.append('orient')
69
+ args.extend(_miller(index) for index in self.orient)
70
+
71
+ if self.duplicate is not None:
72
+ args.append('-duplicate')
73
+ args.extend(map(str, self.duplicate))
74
+
75
+ if include_export and self.export_filename:
76
+ args.append(self.export_filename)
77
+ args.extend(self.formats)
78
+
79
+ return args
80
+
81
+ def to_command(self, *, include_export: bool = True) -> str:
82
+ return shlex.join(self.argv(include_export=include_export))
83
+
84
+ def output_path(self) -> Path | None:
85
+ return Path(self.export_filename) if self.export_filename else None
86
+
87
+ def prepare_run(self) -> None:
88
+ if self.export_filename:
89
+ _prepare_output_path(self.export_filename)
90
+
91
+
92
+ @dataclass
93
+ class CustomAtomicStructure(AtomskCommand):
94
+ cell: Sequence[Sequence[float]]
95
+ basis: Sequence[tuple[str, Sequence[float]]]
96
+ duplicate: Sequence[int] | None = None
97
+ seed_filename: str = 'cementite_unitcell.xsf'
98
+
99
+ def _seed_text(self) -> str:
100
+ lines = [
101
+ 'CRYSTAL',
102
+ 'PRIMVEC',
103
+ *[f'{v[0]} {v[1]} {v[2]}' for v in self.cell],
104
+ 'PRIMCOORD',
105
+ f'{len(self.basis)} 1',
106
+ ]
107
+ for species, frac in self.basis:
108
+ x = frac[0] * self.cell[0][0] + frac[1] * self.cell[1][0] + frac[2] * self.cell[2][0]
109
+ y = frac[0] * self.cell[0][1] + frac[1] * self.cell[1][1] + frac[2] * self.cell[2][1]
110
+ z = frac[0] * self.cell[0][2] + frac[1] * self.cell[1][2] + frac[2] * self.cell[2][2]
111
+ lines.append(f'{species} {x} {y} {z}')
112
+ return '\n'.join(lines) + '\n'
113
+
114
+ def write_seed(self) -> Path:
115
+ path = Path(self.seed_filename)
116
+ path.write_text(self._seed_text())
117
+ return path
118
+
119
+ def argv(self, *, include_export: bool = True) -> list[str]:
120
+ args = ['atomsk', str(Path(self.seed_filename))]
121
+ if self.duplicate is not None:
122
+ args.append('-duplicate')
123
+ args.extend(map(str, self.duplicate))
124
+ return args
125
+
126
+ def to_command(self, *, include_export: bool = True) -> str:
127
+ return shlex.join(self.argv(include_export=include_export))
128
+
129
+ def prepare_run(self) -> None:
130
+ self.write_seed()
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+ from typing import Any, Union
7
+
8
+ from voltsdk import PluginArtifact, SpatialAssembler, open_in_volt
9
+
10
+ Pathish = Union[str, os.PathLike]
11
+ Source = Union[Pathish, PluginArtifact]
12
+
13
+ _GLB_SUFFIXES = {'.glb', '.gltf'}
14
+
15
+
16
+ def _is_glb(path: Path) -> bool:
17
+ return path.suffix.lower() in _GLB_SUFFIXES or path.name.lower().endswith('.glb.zst')
18
+
19
+
20
+ def _as_glb(source: Source, *, output_path: Pathish | None = None, **kwargs: Any) -> Path | str:
21
+ # voltsdk artifacts already know how to convert themselves to GLB.
22
+ if isinstance(source, PluginArtifact):
23
+ return source.glb(output_path=output_path, **kwargs)
24
+
25
+ path = Path(os.fspath(source))
26
+ if _is_glb(path):
27
+ return path
28
+
29
+ target = Path(output_path).expanduser() if output_path else path.with_suffix('.glb')
30
+ return SpatialAssembler().glb(path, output_path=target, **kwargs)
31
+
32
+
33
+ def view(
34
+ source: Source | Sequence[Source],
35
+ *,
36
+ output_path: Pathish | None = None,
37
+ title: str | None = None,
38
+ volt_url: str | None = None,
39
+ open_browser: bool = True,
40
+ **kwargs: Any,
41
+ ) -> str:
42
+ """Open an analysis artifact, a GLB, or a path (or a list of them) in the VOLT canvas.
43
+
44
+ Analysis files (``.json`` / ``.msgpack``) are converted to GLB via voltsdk's
45
+ ``SpatialAssembler`` (the exporter is auto-detected from the payload); GLBs are
46
+ passed through untouched. Compute stays local — ``voltsdk.open_in_volt`` serves the
47
+ asset from a local http server. Returns the viewer URL.
48
+
49
+ ``output_path`` only applies to a single source; a list uses each asset's default
50
+ ``.glb`` path so multiple layers don't collide. Extra keyword arguments (e.g.
51
+ ``exporter='DislocationExporter'``) are forwarded to the GLB conversion.
52
+ """
53
+ if isinstance(source, (str, os.PathLike, PluginArtifact)):
54
+ glb = _as_glb(source, output_path=output_path, **kwargs)
55
+ return open_in_volt(glb, title=title, volt_url=volt_url, open_browser=open_browser)
56
+
57
+ frames = [_as_glb(item, **kwargs) for item in source]
58
+ return open_in_volt(frames, title=title, volt_url=volt_url, open_browser=open_browser)
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyatomsk
3
+ Version: 0.3.0
4
+ Requires-Python: >=3.10
5
+ License-File: LICENSE
6
+ Requires-Dist: voltsdk>=3.0.0
7
+ Dynamic: license-file
8
+ Dynamic: requires-dist
9
+ Dynamic: requires-python
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ pyatomsk/__init__.py
5
+ pyatomsk/atomsk.py
6
+ pyatomsk/commands.py
7
+ pyatomsk/dislocations.py
8
+ pyatomsk/structures.py
9
+ pyatomsk/view.py
10
+ pyatomsk.egg-info/PKG-INFO
11
+ pyatomsk.egg-info/SOURCES.txt
12
+ pyatomsk.egg-info/dependency_links.txt
13
+ pyatomsk.egg-info/requires.txt
14
+ pyatomsk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ voltsdk>=3.0.0
@@ -0,0 +1 @@
1
+ pyatomsk
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ from setuptools import find_packages, setup
2
+
3
+
4
+ setup(
5
+ name="pyatomsk",
6
+ version="0.3.0",
7
+ packages=find_packages(),
8
+ install_requires=[
9
+ "voltsdk>=3.0.0",
10
+ ],
11
+ python_requires=">=3.10",
12
+ )