moukhlijmatextract 1.0.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.
- moukhlijmatextract-1.0.0/Classification/__init__.py +8 -0
- moukhlijmatextract-1.0.0/Classification/__main__.py +13 -0
- moukhlijmatextract-1.0.0/Classification/about.py +19 -0
- moukhlijmatextract-1.0.0/Classification/classify.py +501 -0
- moukhlijmatextract-1.0.0/Classification/cli.py +108 -0
- moukhlijmatextract-1.0.0/Classification/database.py +425 -0
- moukhlijmatextract-1.0.0/Classification/gui.py +554 -0
- moukhlijmatextract-1.0.0/LICENSE +21 -0
- moukhlijmatextract-1.0.0/LocalExtraction/__init__.py +11 -0
- moukhlijmatextract-1.0.0/LocalExtraction/__main__.py +11 -0
- moukhlijmatextract-1.0.0/LocalExtraction/about.py +45 -0
- moukhlijmatextract-1.0.0/LocalExtraction/chemistry.py +827 -0
- moukhlijmatextract-1.0.0/LocalExtraction/cli.py +146 -0
- moukhlijmatextract-1.0.0/LocalExtraction/config.py +308 -0
- moukhlijmatextract-1.0.0/LocalExtraction/context.py +698 -0
- moukhlijmatextract-1.0.0/LocalExtraction/database.py +53 -0
- moukhlijmatextract-1.0.0/LocalExtraction/exporter.py +700 -0
- moukhlijmatextract-1.0.0/LocalExtraction/extractor.py +1527 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/__init__.py +16 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/app.py +30 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/dialogs.py +47 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/panel.py +132 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/results.py +104 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/summary.py +83 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/widgets.py +214 -0
- moukhlijmatextract-1.0.0/LocalExtraction/gui/window.py +206 -0
- moukhlijmatextract-1.0.0/LocalExtraction/metadata.py +248 -0
- moukhlijmatextract-1.0.0/LocalExtraction/models.py +225 -0
- moukhlijmatextract-1.0.0/LocalExtraction/normalizer.py +309 -0
- moukhlijmatextract-1.0.0/LocalExtraction/ocr.py +106 -0
- moukhlijmatextract-1.0.0/LocalExtraction/parser.py +412 -0
- moukhlijmatextract-1.0.0/LocalExtraction/pipeline.py +426 -0
- moukhlijmatextract-1.0.0/LocalExtraction/quantities.py +257 -0
- moukhlijmatextract-1.0.0/LocalExtraction/ranking.py +126 -0
- moukhlijmatextract-1.0.0/LocalExtraction/study.py +362 -0
- moukhlijmatextract-1.0.0/LocalExtraction/transport.py +252 -0
- moukhlijmatextract-1.0.0/PKG-INFO +218 -0
- moukhlijmatextract-1.0.0/README.md +172 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/__init__.py +29 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/__main__.py +12 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/about.py +13 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/cli.py +204 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/console.py +167 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/criteria.py +149 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/criteria.toml +37 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/exporters.py +287 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/gui.py +406 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/html_report.py +45 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/loader.py +286 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/models.py +156 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/palette.py +33 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/report_template.html +495 -0
- moukhlijmatextract-1.0.0/SelectingTopMaterial/selection.py +127 -0
- moukhlijmatextract-1.0.0/WebScraping/Fetch_DOIs/__init__.py +4 -0
- moukhlijmatextract-1.0.0/WebScraping/Fetch_DOIs/get_dois.py +792 -0
- moukhlijmatextract-1.0.0/WebScraping/__init__.py +13 -0
- moukhlijmatextract-1.0.0/WebScraping/__main__.py +21 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/__init__.py +10 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/__main__.py +11 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/about.py +44 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/abstracts.py +146 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/cli.py +122 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/default_job.yaml +610 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/doctor.py +172 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/elsevier.py +77 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/fetch.py +223 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/gui/__init__.py +16 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/gui/app.py +43 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/gui/dialogs.py +392 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/gui/panel.py +436 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/harvest.py +236 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/http.py +200 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/ids.py +83 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/job.py +306 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/ledger.py +306 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/resolve.py +305 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/screening.py +124 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sidecar.py +42 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/__init__.py +36 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/ads.py +80 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/arxiv.py +91 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/common.py +103 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/core.py +77 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/crossref.py +83 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/doaj.py +55 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/europepmc.py +70 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/ieee.py +65 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/jstage.py +73 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/openaire.py +92 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/openalex.py +131 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/scopus.py +77 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/semanticscholar.py +70 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/springernature.py +76 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/sources/wos.py +61 -0
- moukhlijmatextract-1.0.0/WebScraping/averageScrap/wiley.py +59 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/__init__.py +4 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/gui.py +301 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/moukhlij4scraping/__init__.py +2 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/moukhlij4scraping/arxiv.py +132 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/moukhlij4scraping/download.py +206 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/moukhlij4scraping/libgen.py +123 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/moukhlij4scraping/scihub.py +463 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/moukhlij4scraping/tools.py +29 -0
- moukhlijmatextract-1.0.0/WebScraping/moukhlij4scraping/run.py +165 -0
- moukhlijmatextract-1.0.0/WebScraping/webgui.py +88 -0
- moukhlijmatextract-1.0.0/mme_gui/__init__.py +6 -0
- moukhlijmatextract-1.0.0/mme_gui/__main__.py +11 -0
- moukhlijmatextract-1.0.0/mme_gui/hub.py +236 -0
- moukhlijmatextract-1.0.0/moukhlijmatextract.egg-info/PKG-INFO +218 -0
- moukhlijmatextract-1.0.0/moukhlijmatextract.egg-info/SOURCES.txt +114 -0
- moukhlijmatextract-1.0.0/moukhlijmatextract.egg-info/dependency_links.txt +1 -0
- moukhlijmatextract-1.0.0/moukhlijmatextract.egg-info/entry_points.txt +7 -0
- moukhlijmatextract-1.0.0/moukhlijmatextract.egg-info/requires.txt +27 -0
- moukhlijmatextract-1.0.0/moukhlijmatextract.egg-info/top_level.txt +5 -0
- moukhlijmatextract-1.0.0/pyproject.toml +78 -0
- moukhlijmatextract-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Classification/__init__.py
|
|
2
|
+
"""The materials of the MoukhlijMatExtract databases, classified two ways — by crystal-structure type
|
|
3
|
+
(NASICON, LISICON, garnet, argyrodite, ...) and by structure family (oxide, sulfide, halide, ...) —
|
|
4
|
+
with one database per class.
|
|
5
|
+
|
|
6
|
+
python -m Classification (from the project root)
|
|
7
|
+
|
|
8
|
+
It reads LocalExtraction/local_output and WebScraping/web_output and changes neither."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""python -m Classification (from the project root; the window: python -m Classification gui)
|
|
2
|
+
|
|
3
|
+
Part 4 of MoukhlijMatExtract — Crystal and Structure Classification."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
if not __package__: # started as "python Classification": make the project root importable
|
|
9
|
+
sys.path[0] = str(Path(__file__).resolve().parent.parent)
|
|
10
|
+
|
|
11
|
+
from Classification.cli import main
|
|
12
|
+
|
|
13
|
+
sys.exit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Classification/about.py
|
|
2
|
+
"""The name of the part, where it reads, and where it writes."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
NAME = "Crystal and Structure Classification"
|
|
7
|
+
PACKAGE_DIR = Path(__file__).resolve().parent # where the code lives
|
|
8
|
+
# The databases and the class folders live in the folder the user runs in — not where the package
|
|
9
|
+
# is installed — so an installed copy reads part 1/2's database and writes its classes there too.
|
|
10
|
+
PROJECT_ROOT = Path.cwd()
|
|
11
|
+
|
|
12
|
+
# The databases the two extraction parts write, the most trusted first (your own PDFs, then the web).
|
|
13
|
+
DEFAULT_SOURCES = (PROJECT_ROOT / "LocalExtraction" / "local_output", PROJECT_ROOT / "WebScraping" / "web_output")
|
|
14
|
+
DEFAULT_OUT = PROJECT_ROOT / "Classification"
|
|
15
|
+
|
|
16
|
+
# The two ways a material is classified, and the folder each one is written to.
|
|
17
|
+
CRYSTAL_STRUCTURE = "crystal_structure" # the known crystal families: NASICON, LISICON, garnet, argyrodite, ...
|
|
18
|
+
STRUCTURE_FAMILIES = "structure_families" # oxide, sulfide, halide, oxyhalide, nitride, hydride, ...
|
|
19
|
+
UNCLASSIFIED = "unclassified" # only while a material is being classified: none is left so
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Classification/classify.py
|
|
2
|
+
"""Which crystal-structure type and which chemical family a material belongs to.
|
|
3
|
+
|
|
4
|
+
crystal structure the known crystal family it belongs to: NASICON, LISICON, thio-LISICON, garnet,
|
|
5
|
+
argyrodite, perovskite, anti-perovskite, spinel, olivine, layered, rock-salt,
|
|
6
|
+
anti-fluorite, ... — or amorphous / glass-ceramic; a material of none of them
|
|
7
|
+
is "no known crystal family" (a type named after a compound — LGPS-type,
|
|
8
|
+
Li3YCl6-type — is not a family)
|
|
9
|
+
structure family the family of compounds it belongs to, named after its anions: oxide,
|
|
10
|
+
sulfide, halide, oxyhalide, nitride, hydride, ...
|
|
11
|
+
|
|
12
|
+
The structure type is taken, in this order, from what settles it best:
|
|
13
|
+
|
|
14
|
+
1. the composition, when it leaves no doubt (Li7La3Zr2O12 is a garnet, Li6PS5Cl an argyrodite):
|
|
15
|
+
the rule of the extraction itself, LocalExtraction.chemistry.ChemistryEngine.structure_family
|
|
16
|
+
2. the same rule on a composition series taken at its nominal value (Li6+xP1-xGexS5I at x = 0.25)
|
|
17
|
+
3. what the papers state about the material ("NASICON-type", "the garnet LLZO"), the type
|
|
18
|
+
stated by the most papers winning — if at least a third of its papers state it
|
|
19
|
+
4. a state instead of a structure ("glass", "amorphous", "glass-ceramic") — if most of its
|
|
20
|
+
papers state it: a glassy sample in one paper does not make the material a glass
|
|
21
|
+
5. the family its composition type belongs to: a lithium-rich orthooxide A3-4MX4 is LISICON,
|
|
22
|
+
its sulfide thio-LISICON
|
|
23
|
+
6. a family or a state stated by fewer of its papers than above (the basis says how few)
|
|
24
|
+
7. the family of the materials of the same composition type and family (an analogue)
|
|
25
|
+
8. otherwise: no known crystal family — its composition type ("A3MX6 halide") is in a column
|
|
26
|
+
of its own, and a type named after a compound its composition or its papers give
|
|
27
|
+
(LGPS-type, Li3YCl6-type) is said in its basis
|
|
28
|
+
|
|
29
|
+
`crystal_structure_basis` says which of these steps decided — a family the papers state is told
|
|
30
|
+
apart from one inferred.
|
|
31
|
+
|
|
32
|
+
The chemical family always follows from the composition (ChemistryEngine.compound_class), so
|
|
33
|
+
every material with a known composition has one. Each answer says where it came from (`*_basis`)."""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import math
|
|
39
|
+
import re
|
|
40
|
+
from collections import Counter
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
|
|
43
|
+
from LocalExtraction.chemistry import ChemistryEngine, _nominal
|
|
44
|
+
|
|
45
|
+
from .about import UNCLASSIFIED
|
|
46
|
+
|
|
47
|
+
# The transport components an activation energy is told apart by (the extraction's
|
|
48
|
+
# `transport_component`): the barrier inside the grains, across their boundaries, or of the whole sample.
|
|
49
|
+
COMPONENTS = ("bulk", "grain_boundary", "total")
|
|
50
|
+
COMPONENT_WORDS = {"bulk": "bulk", "grain_boundary": "grain-boundary", "total": "total", "surface": "surface"}
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# The labels of the structure column
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# The structure column of the databases holds several kinds of label, "cubic | garnet | Ia-3d":
|
|
56
|
+
# the crystal system, the structure type, the space group, a polymorph, the state.
|
|
57
|
+
CRYSTAL_SYSTEMS = ("cubic", "tetragonal", "orthorhombic", "hexagonal", "trigonal", "rhombohedral", "monoclinic",
|
|
58
|
+
"triclinic")
|
|
59
|
+
_STATES = {"amorphous": "amorphous", "glass": "amorphous", "glassy": "amorphous", "glasses": "amorphous",
|
|
60
|
+
"glass-ceramic": "glass-ceramic", "glass-ceramics": "glass-ceramic"}
|
|
61
|
+
# Every spelling of a structure type, folded (lower case, no space or hyphen) → its one name.
|
|
62
|
+
_TYPES = {
|
|
63
|
+
"garnet": "garnet", "nasicon": "NASICON", "lisicon": "LISICON", "thiolisicon": "thio-LISICON",
|
|
64
|
+
"argyrodite": "argyrodite", "perovskite": "perovskite", "antiperovskite": "anti-perovskite",
|
|
65
|
+
"spinel": "spinel", "olivine": "olivine", "layered": "layered", "rocksalt": "rock-salt",
|
|
66
|
+
"antifluorite": "anti-fluorite", "fluorite": "fluorite", "wurtzite": "wurtzite", "zincblende": "zinc-blende",
|
|
67
|
+
"tavorite": "tavorite",
|
|
68
|
+
}
|
|
69
|
+
# The classes of the crystal axis: the known crystal families, and the two states of no crystal structure.
|
|
70
|
+
KNOWN_FAMILIES = (*dict.fromkeys(_TYPES.values()), "amorphous", "glass-ceramic")
|
|
71
|
+
# What a material of none of them is.
|
|
72
|
+
NO_FAMILY = "no known crystal family"
|
|
73
|
+
# A structure type named after a compound — not a family: "LGPS" (Li10GeP2S12), "Li3YCl6-type", "UCl3-type".
|
|
74
|
+
_PROTOTYPE_NAMES = {"lgps": "LGPS-type", "lgpstype": "LGPS-type"}
|
|
75
|
+
# The stacking of a layered oxide ("P2-type", "O3-type") is a layered structure.
|
|
76
|
+
_STACKING = re.compile(r"^[OPT][1-3]-type$")
|
|
77
|
+
# What the extraction writes in place of a structure when only the kind of compound is known.
|
|
78
|
+
_COMPOUND_CLASSES = {"oxide", "phosphate", "silicate", "borate", "sulfate", "carbonate", "oxynitride", "sulfide",
|
|
79
|
+
"thiophosphate", "selenide", "selenophosphate", "telluride", "tellurophosphate", "fluoride",
|
|
80
|
+
"chloride", "bromide", "iodide", "mixed halide", "oxyhalide", "nitride", "hydride",
|
|
81
|
+
"borohydride", "closo-borate", "alloy", "element"}
|
|
82
|
+
_MISSING = {"", "none", "nan", "null", "n/a", "unspecified", "unknown", "open"}
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Composition types and prototypes
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
_MOBILE = {"Li", "Na", "K", "Rb", "Cs", "Ag", "Cu"} # A: the mobile cations
|
|
88
|
+
_ANIONS = {"O", "S", "Se", "Te", "F", "Cl", "Br", "I", "N"} # X
|
|
89
|
+
_TETRAHEDRAL = {"P", "Si", "Ge", "V", "As", "Sn", "Sb"} # the centres of the XO4 / XS4 groups
|
|
90
|
+
# The centres of the polyanions of a framework — PO4, SiO4, PS4, GeS4, BO3, CO3, SO4, WO4, ...
|
|
91
|
+
_POLYANION_CENTRES = _TETRAHEDRAL | {"B", "C", "S", "Mo", "W"}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def has_open_shares(amounts: dict[str, float | str]) -> bool:
|
|
95
|
+
"""Whether the composition is a mixture whose shares the paper leaves open ("xLi2S-(100-x)SiS2"):
|
|
96
|
+
its elements are known, their amounts are not."""
|
|
97
|
+
return any(isinstance(amount, str) and amount.strip().casefold() == "open" for amount in amounts.values())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _amounts(amounts: dict[str, float | str]) -> dict[str, float]:
|
|
101
|
+
return {symbol: (amount if isinstance(amount, float) else _nominal(amount)) for symbol, amount in amounts.items()}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _integral(counts: tuple[float, float, float]) -> tuple[int, int, int]:
|
|
105
|
+
"""Whole numbers for A, M and X: the smallest multiple that makes them whole (Li1.5Al0.5Ge1.5(PO4)3
|
|
106
|
+
→ A3M4X24 ...), else each rounded — a dopant-level share dropped — so that a doped variant is of
|
|
107
|
+
the type of its parent (Li2.6Y0.8Zr0.2Cl6 is an A3MX6 halide like Li3YCl6)."""
|
|
108
|
+
for k in (1, 2, 3, 4):
|
|
109
|
+
scaled = [count * k for count in counts]
|
|
110
|
+
if all(abs(v - round(v)) <= 0.12 and round(v) >= 1 for v in scaled if v > 0):
|
|
111
|
+
return tuple(round(v) if v > 0 else 0 for v in scaled) # type: ignore[return-value]
|
|
112
|
+
return tuple(max(1, round(v)) if v > 0.3 else 0 for v in counts) # type: ignore[return-value]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def composition_type(amounts: dict[str, float | str]) -> tuple[str, float, float, float] | None:
|
|
116
|
+
"""The formula a material shares with its kind — "A3MX6" for Li3YCl6, Na3YI6 and Li3InCl6 —
|
|
117
|
+
with its A (mobile cation), M (other cation) and X (anion) contents per formula unit. None for
|
|
118
|
+
a mixture whose shares are open: its formula is not known."""
|
|
119
|
+
if has_open_shares(amounts):
|
|
120
|
+
return None
|
|
121
|
+
numbers = _amounts(amounts)
|
|
122
|
+
anions = _ANIONS if numbers.keys() & _ANIONS else {"P", "As", "Sb"} # a phosphide: Li3P, Li8SiP4
|
|
123
|
+
a = sum(v for k, v in numbers.items() if k in _MOBILE)
|
|
124
|
+
x = sum(v for k, v in numbers.items() if k in anions)
|
|
125
|
+
m = sum(v for k, v in numbers.items() if k not in _MOBILE | anions and k != "H")
|
|
126
|
+
if not x or not (a or m):
|
|
127
|
+
return None
|
|
128
|
+
unit = m if m else a
|
|
129
|
+
a, m, x = a / unit, m / unit, x / unit
|
|
130
|
+
whole = _integral((a, m, x))
|
|
131
|
+
|
|
132
|
+
def part(symbol: str, count: int) -> str:
|
|
133
|
+
return "" if count <= 0 else symbol + ("" if count == 1 else str(count))
|
|
134
|
+
return part("A", whole[0]) + part("M", whole[1]) + part("X", whole[2]), round(a, 2), round(m, 2), round(x, 2)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def prototype(amounts: dict[str, float | str]) -> str | None:
|
|
138
|
+
"""The known family a material's composition type belongs to, where that settles it: a
|
|
139
|
+
lithium-rich orthooxide A3-4MX4 (γ-Li3PO4, Li4SiO4) is LISICON, its sulfide (Li3PS4, Li4GeS4)
|
|
140
|
+
thio-LISICON. None elsewhere. (A sodium thiophosphate such as Na3PS4 is neither: a type of its own.)"""
|
|
141
|
+
kind = composition_type(amounts)
|
|
142
|
+
if not kind:
|
|
143
|
+
return None
|
|
144
|
+
_, a, m, x = kind
|
|
145
|
+
present = set(amounts)
|
|
146
|
+
anions = present & _ANIONS
|
|
147
|
+
numbers = _amounts(amounts)
|
|
148
|
+
lithium, sodium = numbers.get("Li", 0.0), numbers.get("Na", 0.0)
|
|
149
|
+
centres = present & _TETRAHEDRAL
|
|
150
|
+
others = {e for e in present - _MOBILE - _ANIONS - _TETRAHEDRAL if numbers.get(e, 0.0) >= 0.2} # a dopant aside
|
|
151
|
+
if lithium > sodium and centres and not others and 2.5 <= a <= 4.5 and abs(x - 4) <= 0.1 and m <= 1.05:
|
|
152
|
+
if anions == {"O"}:
|
|
153
|
+
return "LISICON" # γ-Li3PO4, Li4SiO4, Li4GeO4 and their solid solutions
|
|
154
|
+
if anions == {"S"}:
|
|
155
|
+
return "thio-LISICON" # Li3PS4, Li4GeS4, Li4SnS4
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Labels:
|
|
161
|
+
"""The labels of one structure cell, by kind."""
|
|
162
|
+
types: tuple[str, ...] = ()
|
|
163
|
+
systems: tuple[str, ...] = ()
|
|
164
|
+
polymorphs: tuple[str, ...] = ()
|
|
165
|
+
space_groups: tuple[str, ...] = ()
|
|
166
|
+
states: tuple[str, ...] = ()
|
|
167
|
+
prototypes: tuple[str, ...] = () # types named after a compound: not a family
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def structure_labels(cell: str) -> Labels:
|
|
171
|
+
"""'cubic | garnet | Ia-3d' → the type garnet, the system cubic, the space group Ia-3d."""
|
|
172
|
+
found: dict[str, list[str]] = {"types": [], "systems": [], "polymorphs": [], "space_groups": [], "states": [],
|
|
173
|
+
"prototypes": []}
|
|
174
|
+
for label in (part.strip() for part in str(cell or "").split("|")):
|
|
175
|
+
lowered = label.casefold()
|
|
176
|
+
if lowered in _MISSING or lowered in _COMPOUND_CLASSES or lowered == "crystalline":
|
|
177
|
+
continue
|
|
178
|
+
folded = re.sub(r"[\s\-]", "", lowered)
|
|
179
|
+
if lowered in CRYSTAL_SYSTEMS:
|
|
180
|
+
found["systems"].append(lowered)
|
|
181
|
+
elif lowered in _STATES:
|
|
182
|
+
found["states"].append(_STATES[lowered])
|
|
183
|
+
elif label.endswith("-phase"):
|
|
184
|
+
found["polymorphs"].append(label)
|
|
185
|
+
elif folded in _TYPES:
|
|
186
|
+
found["types"].append(_TYPES[folded])
|
|
187
|
+
elif _STACKING.match(label):
|
|
188
|
+
found["types"].append("layered")
|
|
189
|
+
elif folded in _PROTOTYPE_NAMES:
|
|
190
|
+
found["prototypes"].append(_PROTOTYPE_NAMES[folded])
|
|
191
|
+
elif label.endswith("-type"): # a type named after a compound: "UCl3-type", "Li3YCl6-type"
|
|
192
|
+
found["prototypes"].append(label)
|
|
193
|
+
else:
|
|
194
|
+
found["space_groups"].append(label)
|
|
195
|
+
return Labels(**{kind: tuple(dict.fromkeys(labels)) for kind, labels in found.items()})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Compositions
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
def composition(cell: str) -> dict[str, float | str]:
|
|
202
|
+
"""The composition of the `elements` / `composition` column: {"Li": 7.0, "La": 3.0, ...};
|
|
203
|
+
an open subscript of a series stays text ("1-x")."""
|
|
204
|
+
try:
|
|
205
|
+
amounts = json.loads(cell) if cell and str(cell).strip().startswith("{") else {}
|
|
206
|
+
except ValueError:
|
|
207
|
+
return {}
|
|
208
|
+
if not isinstance(amounts, dict):
|
|
209
|
+
return {}
|
|
210
|
+
return {symbol: (float(amount) if isinstance(amount, (int, float)) and not isinstance(amount, bool) else str(amount))
|
|
211
|
+
for symbol, amount in amounts.items()}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def material_key(amounts: dict[str, float | str], formula: str) -> str:
|
|
215
|
+
"""What makes two rows one material: the composition when there is one (Li1.3Al0.3Ti1.7(PO4)3
|
|
216
|
+
and Li1.3Ti1.7Al0.3(PO4)3 are one), otherwise the formula as it is spelled."""
|
|
217
|
+
if amounts:
|
|
218
|
+
return " ".join(f"{symbol}{round(amount, 4):g}" if isinstance(amount, float) else f"{symbol}[{''.join(amount.split())}]"
|
|
219
|
+
for symbol, amount in sorted(amounts.items()))
|
|
220
|
+
return "formula:" + "".join(str(formula).split())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def type_from_composition(amounts: dict[str, float | str]) -> tuple[str | None, str]:
|
|
224
|
+
"""The structure type the composition alone settles, and how it was settled — nothing for a
|
|
225
|
+
mixture whose shares are open."""
|
|
226
|
+
if not amounts or has_open_shares(amounts):
|
|
227
|
+
return None, ""
|
|
228
|
+
if all(isinstance(amount, float) for amount in amounts.values()):
|
|
229
|
+
found = ChemistryEngine.structure_family(amounts)
|
|
230
|
+
return (found, "known for this composition") if found else (None, "")
|
|
231
|
+
nominal = {symbol: _nominal(amount) for symbol, amount in amounts.items()}
|
|
232
|
+
found = ChemistryEngine.structure_family(nominal)
|
|
233
|
+
return (found, "known for this composition series (taken at x = 0.25)") if found else (None, "")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Chemical families
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# What part of its papers must state a type (or a state) for it to classify a material.
|
|
240
|
+
TYPE_SHARE, STATE_SHARE = 1 / 3, 1 / 2
|
|
241
|
+
|
|
242
|
+
# The kind of compound the extraction names (after its anions) → the family it belongs to.
|
|
243
|
+
FAMILIES = {
|
|
244
|
+
"oxide": ("oxide", "phosphate", "silicate", "borate", "sulfate", "carbonate", "oxynitride"),
|
|
245
|
+
"sulfide": ("sulfide", "thiophosphate"),
|
|
246
|
+
"selenide": ("selenide", "selenophosphate"),
|
|
247
|
+
"telluride": ("telluride", "tellurophosphate"),
|
|
248
|
+
"halide": ("fluoride", "chloride", "bromide", "iodide", "mixed halide"),
|
|
249
|
+
"oxyhalide": ("oxyhalide",),
|
|
250
|
+
"nitride": ("nitride",),
|
|
251
|
+
"phosphide": ("phosphide", "arsenide"),
|
|
252
|
+
"hydride": ("hydride", "borohydride", "closo-borate"),
|
|
253
|
+
"element_or_alloy": ("element", "alloy"),
|
|
254
|
+
}
|
|
255
|
+
_FAMILY_OF = {kind: family for family, kinds in FAMILIES.items() for kind in kinds}
|
|
256
|
+
_ELECTROPOSITIVE = {"Li", "Na", "K", "Rb", "Cs", "Mg", "Ca", "Sr", "Ba"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def chemical_family(amounts: dict[str, float | str], formula: str) -> tuple[str, str]:
|
|
260
|
+
"""The family and the kind of compound: ("sulfide", "thiophosphate") for Li6PS5Cl."""
|
|
261
|
+
if not amounts and not re.search(r"[A-Z]", str(formula)):
|
|
262
|
+
return UNCLASSIFIED, ""
|
|
263
|
+
kind = ChemistryEngine.compound_class(amounts, str(formula))
|
|
264
|
+
if kind in ("alloy", "element"):
|
|
265
|
+
# No O, S, halogen or N: a pnictogen with an electropositive metal is its anion (Li3P, Li8SiP4).
|
|
266
|
+
present = set(amounts) or set(re.findall(r"[A-Z][a-z]?", str(formula)))
|
|
267
|
+
if present & _ELECTROPOSITIVE:
|
|
268
|
+
kind = "phosphide" if "P" in present else "arsenide" if "As" in present else kind
|
|
269
|
+
return _FAMILY_OF.get(kind, UNCLASSIFIED), kind
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# One material, from all its rows
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
_STATE_CLASSES = {"amorphous", "glass-ceramic"}
|
|
276
|
+
# What classified a material well enough for it to stand as an analogue of another: its composition,
|
|
277
|
+
# what most of its papers state, or a prototype — not a type few papers state, nor a state.
|
|
278
|
+
_ESTABLISHED = ("known for this composition", "stated by ", "inferred: the family of its composition type")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def roles(amounts: dict[str, float | str]) -> tuple[frozenset, frozenset, frozenset]:
|
|
282
|
+
"""The mobile cations, the main framework cations (a dopant aside) and the anions of a
|
|
283
|
+
composition."""
|
|
284
|
+
numbers = _amounts(amounts)
|
|
285
|
+
anions = _ANIONS if numbers.keys() & _ANIONS else {"P", "As", "Sb"}
|
|
286
|
+
framework = {e: v for e, v in numbers.items() if e not in _MOBILE | anions and e != "H"}
|
|
287
|
+
largest = max(framework.values(), default=0.0)
|
|
288
|
+
main = {e for e, v in framework.items() if v >= 0.2 * largest}
|
|
289
|
+
return (frozenset(e for e in numbers if e in _MOBILE), frozenset(main), frozenset(e for e in numbers if e in anions))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def analogous(a: "Material", b: "Material") -> bool:
|
|
293
|
+
"""Whether two materials are analogues — a doped or substituted variant of the other: the same
|
|
294
|
+
composition type and family, the same stoichiometry (A, M and X per formula unit within 15%),
|
|
295
|
+
the same anions and mobile cations, the same polyanion centres (P, Si, Ge, ...) and a main
|
|
296
|
+
framework cation in common. Na2.88Sb0.88W0.12S4 is one of Na3SbS4, Li3Er0.6In0.4Cl6 of
|
|
297
|
+
Li3ErCl6; ZrO2 is none of a zirconium phosphate, LiBiO2 none of a garnet, LixMn2O4 none of a
|
|
298
|
+
perovskite titanate — and Li3ScCl6 none of Li3YCl6, as the Li3MX6 halides change structure with
|
|
299
|
+
the size of M (only the symmetry their papers state tells them apart: step 5)."""
|
|
300
|
+
kind_a, kind_b = composition_type(a.amounts), composition_type(b.amounts)
|
|
301
|
+
if not kind_a or not kind_b or a.kind() != b.kind():
|
|
302
|
+
return False
|
|
303
|
+
if any(abs(u - v) > 0.15 * max(u, v, 0.1) for u, v in zip(kind_a[1:], kind_b[1:])):
|
|
304
|
+
return False
|
|
305
|
+
(mobile_a, main_a, anions_a), (mobile_b, main_b, anions_b) = roles(a.amounts), roles(b.amounts)
|
|
306
|
+
centres_a, centres_b = (main_a & _POLYANION_CENTRES) - anions_a, (main_b & _POLYANION_CENTRES) - anions_b
|
|
307
|
+
return mobile_a == mobile_b and anions_a == anions_b and centres_a == centres_b and bool(main_a & main_b)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def classify_the_rest(materials: list["Material"]) -> None:
|
|
311
|
+
"""Steps 7 and 8, for the materials nothing about themselves classifies: the family of their
|
|
312
|
+
analogues — when those agree on one — else no known crystal family."""
|
|
313
|
+
established = [m for m in materials if m.structure_type not in (UNCLASSIFIED, *_STATE_CLASSES)
|
|
314
|
+
and m.structure_type_basis.startswith(_ESTABLISHED)
|
|
315
|
+
and not m.structure_type_basis.startswith("stated by only")]
|
|
316
|
+
for m in materials:
|
|
317
|
+
if m.structure_type != UNCLASSIFIED:
|
|
318
|
+
continue
|
|
319
|
+
kind = m.kind()
|
|
320
|
+
# nor from its analogues, for a material of a type named after a compound (see Material.classify)
|
|
321
|
+
alike = [] if m.prototypes else [other for other in established if analogous(m, other)]
|
|
322
|
+
votes = Counter(other.structure_type for other in alike)
|
|
323
|
+
if votes:
|
|
324
|
+
label, count = votes.most_common(1)[0]
|
|
325
|
+
if count >= 2 * sum(votes.values()) / 3:
|
|
326
|
+
names = ", ".join(sorted(other.name for other in alike if other.structure_type == label)[:3])
|
|
327
|
+
m.structure_type = label
|
|
328
|
+
m.structure_type_basis = (f"inferred: the type of its analogues, {kind}s of the same ions "
|
|
329
|
+
f"({count} of {sum(votes.values())}: {names})")
|
|
330
|
+
continue
|
|
331
|
+
what = (f"its composition type is {kind}" if composition_type(m.amounts) else
|
|
332
|
+
f"a {m.family} mixture whose shares are open" if has_open_shares(m.amounts) else
|
|
333
|
+
"an element" if len(m.amounts) == 1 else "an intermetallic")
|
|
334
|
+
named = m.prototypes.most_common()
|
|
335
|
+
m.structure_type = NO_FAMILY
|
|
336
|
+
m.structure_type_basis = (f"none of the known crystal families is stated by its papers, known for its "
|
|
337
|
+
f"composition or shared by its analogues; {what}"
|
|
338
|
+
+ (f"; a type named after a compound is given for it: "
|
|
339
|
+
f"{', '.join(label for label, _ in named)}" if named else ""))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass
|
|
343
|
+
class Material:
|
|
344
|
+
key: str
|
|
345
|
+
amounts: dict[str, float | str]
|
|
346
|
+
spellings: Counter = field(default_factory=Counter)
|
|
347
|
+
occurrences: list[tuple[str, str]] = field(default_factory=list) # (database, paper_id)
|
|
348
|
+
stated_types: dict[str, set] = field(default_factory=dict) # type → the papers that state it
|
|
349
|
+
systems: Counter = field(default_factory=Counter)
|
|
350
|
+
polymorphs: Counter = field(default_factory=Counter)
|
|
351
|
+
space_groups: Counter = field(default_factory=Counter)
|
|
352
|
+
states: dict[str, set] = field(default_factory=dict)
|
|
353
|
+
prototypes: Counter = field(default_factory=Counter) # types named after a compound: stated or known
|
|
354
|
+
structure_type: str = UNCLASSIFIED
|
|
355
|
+
structure_type_basis: str = ""
|
|
356
|
+
family: str = UNCLASSIFIED
|
|
357
|
+
compound_class: str = ""
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def name(self) -> str:
|
|
361
|
+
"""Its most frequent spelling, the shortest one among equals."""
|
|
362
|
+
return min(self.spellings, key=lambda spelling: (-self.spellings[spelling], len(spelling), spelling))
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def papers(self) -> list[str]:
|
|
366
|
+
return list(dict.fromkeys(paper for _, paper in self.occurrences))
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def databases(self) -> list[str]:
|
|
370
|
+
return list(dict.fromkeys(database for database, _ in self.occurrences))
|
|
371
|
+
|
|
372
|
+
def add(self, database: str, paper: str, formula: str, structure: str) -> None:
|
|
373
|
+
self.spellings[formula] += 1
|
|
374
|
+
self.occurrences.append((database, paper))
|
|
375
|
+
labels = structure_labels(structure)
|
|
376
|
+
for label in labels.types:
|
|
377
|
+
self.stated_types.setdefault(label, set()).add((database, paper))
|
|
378
|
+
for label in labels.states:
|
|
379
|
+
self.states.setdefault(label, set()).add((database, paper))
|
|
380
|
+
self.prototypes.update(labels.prototypes)
|
|
381
|
+
self.systems.update(labels.systems)
|
|
382
|
+
self.polymorphs.update(labels.polymorphs)
|
|
383
|
+
self.space_groups.update(labels.space_groups)
|
|
384
|
+
|
|
385
|
+
def votes(self) -> str:
|
|
386
|
+
"""What the papers state, most stated first: "NASICON 3 papers · garnet 1 paper"."""
|
|
387
|
+
ranked = sorted(self.stated_types.items(), key=lambda item: (-len(item[1]), item[0]))
|
|
388
|
+
return " · ".join(f"{label} {len(papers)} paper{'s' if len(papers) != 1 else ''}" for label, papers in ranked)
|
|
389
|
+
|
|
390
|
+
def classify(self) -> None:
|
|
391
|
+
"""Steps 1 to 6: what the material itself settles. Steps 7 and 8 need the other materials:
|
|
392
|
+
see `classify_the_rest`."""
|
|
393
|
+
self.family, self.compound_class = chemical_family(self.amounts, self.name)
|
|
394
|
+
if self.family == "halide":
|
|
395
|
+
# "Layered" in a paper on a Li3MX6 halide describes the anion stacking its monoclinic and
|
|
396
|
+
# trigonal phases share — not the family of the layered oxides.
|
|
397
|
+
self.stated_types.pop("layered", None)
|
|
398
|
+
found, basis = type_from_composition(self.amounts)
|
|
399
|
+
if found and found not in KNOWN_FAMILIES: # LGPS-type: a type named after a compound, not a family
|
|
400
|
+
self.prototypes[found] += 1
|
|
401
|
+
found = None
|
|
402
|
+
if found:
|
|
403
|
+
stated = set(self.stated_types) - {found}
|
|
404
|
+
note = f"; the papers also state {', '.join(sorted(stated))}" if stated else ""
|
|
405
|
+
self.structure_type, self.structure_type_basis = found, basis + note
|
|
406
|
+
return
|
|
407
|
+
self.structure_type, self.structure_type_basis = self._stated()
|
|
408
|
+
if self.structure_type != UNCLASSIFIED:
|
|
409
|
+
return
|
|
410
|
+
# A material of a type named after a compound (LGPS-type, Li3YCl6-type) has a structure of its own:
|
|
411
|
+
# no family is inferred for it from its formula — Li10GeP2S12 has the formula of a thio-LISICON, not
|
|
412
|
+
# its structure. Only papers stating a family classify it.
|
|
413
|
+
found = None if self.prototypes else prototype(self.amounts)
|
|
414
|
+
if found:
|
|
415
|
+
self.structure_type = found
|
|
416
|
+
self.structure_type_basis = f"inferred: the family of its composition type ({self.kind()})"
|
|
417
|
+
return
|
|
418
|
+
if self.stated_types or self.states:
|
|
419
|
+
label, papers = max([*self.stated_types.items(), *self.states.items()], key=lambda item: (len(item[1]), item[0]))
|
|
420
|
+
self.structure_type = label
|
|
421
|
+
self.structure_type_basis = f"stated by only {len(papers)} of the {len(self.papers)} paper(s) of this material"
|
|
422
|
+
|
|
423
|
+
def kind(self) -> str:
|
|
424
|
+
"""Its composition type, "A3MX6 halide"."""
|
|
425
|
+
found = composition_type(self.amounts)
|
|
426
|
+
return f"{found[0]} {self.family}" if found else self.family
|
|
427
|
+
|
|
428
|
+
def _stated(self) -> tuple[str, str]:
|
|
429
|
+
"""The class what the papers state gives, when enough of them state it; why not, otherwise."""
|
|
430
|
+
total = len(self.papers)
|
|
431
|
+
too_few = []
|
|
432
|
+
if self.stated_types:
|
|
433
|
+
ranked = sorted(self.stated_types.items(), key=lambda item: (-len(item[1]), item[0]))
|
|
434
|
+
label, papers = ranked[0]
|
|
435
|
+
if len(papers) >= TYPE_SHARE * total:
|
|
436
|
+
basis = f"stated by {len(papers)} of the {total} paper(s) of this material"
|
|
437
|
+
if len(ranked) > 1:
|
|
438
|
+
basis += f" (also stated: {', '.join(other for other, _ in ranked[1:])})"
|
|
439
|
+
return label, basis
|
|
440
|
+
too_few.append(f"{label} by {len(papers)} of {total}")
|
|
441
|
+
if self.states:
|
|
442
|
+
label, papers = max(self.states.items(), key=lambda item: (len(item[1]), item[0] == "amorphous"))
|
|
443
|
+
if len(papers) > STATE_SHARE * total:
|
|
444
|
+
return label, f"stated by {len(papers)} of the {total} paper(s): no crystal structure"
|
|
445
|
+
too_few.append(f"{label} by {len(papers)} of {total}")
|
|
446
|
+
if too_few:
|
|
447
|
+
return UNCLASSIFIED, f"too few of its papers state a structure ({'; '.join(too_few)})"
|
|
448
|
+
return UNCLASSIFIED, "not stated by its papers and not known for this composition"
|
|
449
|
+
|
|
450
|
+
def row(self, energies: list[float], conductivities: list[float],
|
|
451
|
+
by_component: dict[str, list[float]] | None = None) -> dict[str, object]:
|
|
452
|
+
"""Its line in materials.csv of a classification folder. `by_component`: its activation
|
|
453
|
+
energies by the transport component they describe (bulk, grain_boundary, total)."""
|
|
454
|
+
def joined(counter: Counter) -> str:
|
|
455
|
+
return " | ".join(label for label, _ in counter.most_common()) or "none stated by its papers"
|
|
456
|
+
|
|
457
|
+
energies, conductivities = sorted(energies), sorted(conductivities)
|
|
458
|
+
components: dict[str, object] = {}
|
|
459
|
+
for component in COMPONENTS:
|
|
460
|
+
values = sorted((by_component or {}).get(component, []))
|
|
461
|
+
components[f"n_Ea_{component}"] = len(values)
|
|
462
|
+
components[f"Ea_{component}_min_eV"] = values[0] if values else f"no {COMPONENT_WORDS[component]} value reported"
|
|
463
|
+
return {
|
|
464
|
+
"material": self.name,
|
|
465
|
+
"spellings": " | ".join(s for s, _ in self.spellings.most_common()),
|
|
466
|
+
"composition": json.dumps(self.amounts, ensure_ascii=False) if self.amounts else "its formula names no element",
|
|
467
|
+
"crystal_structure": self.structure_type,
|
|
468
|
+
"crystal_structure_basis": self.structure_type_basis,
|
|
469
|
+
"structure_types_stated": self.votes() or "none stated by its papers",
|
|
470
|
+
"composition_type": self.kind(),
|
|
471
|
+
"structure_family": self.family,
|
|
472
|
+
"compound_class": self.compound_class or self.family,
|
|
473
|
+
"crystal_systems": joined(self.systems),
|
|
474
|
+
"polymorphs": joined(self.polymorphs),
|
|
475
|
+
"space_groups": joined(self.space_groups),
|
|
476
|
+
"n_papers": len(self.papers),
|
|
477
|
+
"papers": " | ".join(self.papers),
|
|
478
|
+
"databases": " | ".join(self.databases),
|
|
479
|
+
"n_activation_energies": len(energies),
|
|
480
|
+
"Ea_min_eV": energies[0] if energies else "no activation energy reported",
|
|
481
|
+
"Ea_median_eV": _median(energies) if energies else "no activation energy reported",
|
|
482
|
+
"Ea_max_eV": energies[-1] if energies else "no activation energy reported",
|
|
483
|
+
**components,
|
|
484
|
+
"n_ionic_conductivities": len(conductivities),
|
|
485
|
+
"sigma_max_S_cm": conductivities[-1] if conductivities else "no ionic conductivity reported",
|
|
486
|
+
"material_key": self.key,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _median(values: list[float]) -> float:
|
|
491
|
+
middle = len(values) // 2
|
|
492
|
+
return values[middle] if len(values) % 2 else round((values[middle - 1] + values[middle]) / 2, 6)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def number(text: object) -> float | None:
|
|
496
|
+
"""The number at the start of a cell: "0.31 eV" → 0.31, "1e-3 S/cm" → 0.001."""
|
|
497
|
+
match = re.match(r"\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)", str(text))
|
|
498
|
+
if not match:
|
|
499
|
+
return None
|
|
500
|
+
value = float(match.group(1))
|
|
501
|
+
return value if math.isfinite(value) else None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Classification/cli.py
|
|
2
|
+
"""The command line of the classification. Run from the project root.
|
|
3
|
+
|
|
4
|
+
python -m Classification both databases → this folder
|
|
5
|
+
python -m Classification --source WebScraping/web_output
|
|
6
|
+
python -m Classification --out some/folder
|
|
7
|
+
python -m Classification gui the window
|
|
8
|
+
|
|
9
|
+
It reads LocalExtraction/local_output and WebScraping/web_output (whichever exist) and writes:
|
|
10
|
+
|
|
11
|
+
crystal_structure/<type>/ NASICON, LISICON, garnet, argyrodite, ... one database each
|
|
12
|
+
structure_families/<family>/ oxide, sulfide, halide, oxyhalide, ... one database each
|
|
13
|
+
all_materials.csv every material with its two classes"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .about import CRYSTAL_STRUCTURE, DEFAULT_OUT, DEFAULT_SOURCES, NAME, PROJECT_ROOT, STRUCTURE_FAMILIES
|
|
23
|
+
from .classify import COMPONENT_WORDS, NO_FAMILY
|
|
24
|
+
from .database import AXES, BY_COMPONENT, classify, read, write
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="python -m Classification",
|
|
30
|
+
description="Classifies the materials of the MoukhlijMatExtract databases by crystal-structure type "
|
|
31
|
+
"(NASICON, LISICON, garnet, ...) and by structure family (oxide, sulfide, halide, ...), "
|
|
32
|
+
"and writes one database per class.",
|
|
33
|
+
epilog=f'The window: python "{NAME}" gui. MoukhlijMatExtract (MME) — Created by Younes Moukhlij.')
|
|
34
|
+
parser.add_argument("--source", "-s", nargs="+", action="extend", metavar="DIR",
|
|
35
|
+
help="Database folder(s) to read, the most trusted first (default: "
|
|
36
|
+
"LocalExtraction/local_output and WebScraping/web_output, whichever exist)")
|
|
37
|
+
parser.add_argument("--out", "-o", default=str(DEFAULT_OUT), metavar="DIR",
|
|
38
|
+
help=f"Folder to write the classes into (default: {NAME}/)")
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _folder(name: str) -> Path:
|
|
43
|
+
path = Path(name).expanduser()
|
|
44
|
+
return path if path.exists() or path.is_absolute() else PROJECT_ROOT / path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _shown(path: Path) -> str:
|
|
48
|
+
try:
|
|
49
|
+
return str(path.resolve().relative_to(Path.cwd().resolve()))
|
|
50
|
+
except ValueError:
|
|
51
|
+
return str(path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _count(n: int, word: str) -> str:
|
|
55
|
+
return f"{n:>4} {word}{'' if n == 1 else 's'}" + (" " if n == 1 else "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
59
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
60
|
+
if argv[:1] == ["gui"]: # the window: python -m Classification gui
|
|
61
|
+
from .gui import main as window
|
|
62
|
+
return window(argv[1:])
|
|
63
|
+
args = build_parser().parse_args(argv)
|
|
64
|
+
folders = [_folder(name) for name in args.source] if args.source else list(DEFAULT_SOURCES)
|
|
65
|
+
out_dir = Path(args.out).expanduser()
|
|
66
|
+
|
|
67
|
+
sources = []
|
|
68
|
+
for folder in folders:
|
|
69
|
+
source = read(folder)
|
|
70
|
+
if source is None:
|
|
71
|
+
print(f" {_shown(folder)}: no database here (no materials.csv) — skipped", file=sys.stderr)
|
|
72
|
+
continue
|
|
73
|
+
if source.path.resolve() == out_dir.resolve():
|
|
74
|
+
print(f"{NAME}: {out_dir} is a database it reads: choose another --out.", file=sys.stderr)
|
|
75
|
+
return 2
|
|
76
|
+
sources.append(source)
|
|
77
|
+
print(f" Read {_shown(folder)}: {len(source.tables['materials'])} material rows, "
|
|
78
|
+
f"{len(source.tables['activation_energy'])} activation energies")
|
|
79
|
+
if not sources:
|
|
80
|
+
print(f"{NAME}: no database to classify. Run the extraction first "
|
|
81
|
+
"(python -m LocalExtraction or python -m WebScraping), or name one with --source.", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
classified = classify(sources)
|
|
85
|
+
summaries = write(classified, out_dir)
|
|
86
|
+
print(f"\n {len(classified.materials)} materials classified into {_shown(out_dir)}/\n")
|
|
87
|
+
for axis, _, column in AXES:
|
|
88
|
+
title = {CRYSTAL_STRUCTURE: "Crystal structure", STRUCTURE_FAMILIES: "Structure families"}[axis]
|
|
89
|
+
print(f" {title} ({axis}/)")
|
|
90
|
+
rows = summaries[axis].to_dict("records")
|
|
91
|
+
subs = summaries[f"{axis}/{BY_COMPONENT}"]
|
|
92
|
+
for row in rows:
|
|
93
|
+
print(f" {row[column]:<26} {_count(row['n_materials'], 'material')} {row['n_activation_energies']:>4} Ea "
|
|
94
|
+
f"{_count(row['n_papers'], 'paper')}")
|
|
95
|
+
mine = subs[subs[column] == row[column]].to_dict("records")
|
|
96
|
+
for index, sub in enumerate(mine): # its sub-classes: bulk, grain boundary, total
|
|
97
|
+
branch = "└─" if index == len(mine) - 1 else "├─"
|
|
98
|
+
word = COMPONENT_WORDS.get(sub["component"], sub["component"])
|
|
99
|
+
lowest = f"lowest {sub['min_eV']} eV ({sub['min_material']})" if sub["n_values"] else "no activation energy"
|
|
100
|
+
print(f" {branch} {word:<21} {_count(sub['n_materials'], 'material')} {sub['n_values']:>4} Ea {lowest}")
|
|
101
|
+
if axis == CRYSTAL_STRUCTURE:
|
|
102
|
+
none = sum(1 for m in classified.materials.values() if m.structure_type == NO_FAMILY)
|
|
103
|
+
if none:
|
|
104
|
+
print(f" + {none} materials of no known crystal family: in all_materials.csv and in their "
|
|
105
|
+
f"structure family, with their composition type")
|
|
106
|
+
print()
|
|
107
|
+
print(f" Every material with its two classes: {_shown(out_dir / 'all_materials.csv')}")
|
|
108
|
+
return 0
|