mat-data-handler 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,18 @@
1
+ """Public API: validate/combine material YAML entries and export Abaqus .inc files.
2
+
3
+ See :mod:`mat_data_handler.database` for building the combined database and
4
+ :mod:`mat_data_handler.extract` for exporting selected materials to Abaqus
5
+ ``PROPS`` include files.
6
+ """
7
+
8
+ from importlib import metadata
9
+
10
+ try:
11
+ __version__ = metadata.version("mat-data-handler")
12
+ except metadata.PackageNotFoundError: # local checkout, not installed
13
+ __version__ = "0.0.0+dev"
14
+
15
+ from .database import build_database, collect, validate_only
16
+ from .extract import export_material
17
+
18
+ __all__ = ["build_database", "collect", "validate_only", "export_material", "__version__"]
@@ -0,0 +1,89 @@
1
+ """Resolve the mat-data material dataset over the network (no pip/conda dependency).
2
+
3
+ The material entries, JSON schemas, and PROPS mapping table live in the
4
+ separate `mat-data` repository (https://github.com/ICAMS/mat-data), not as an
5
+ installable package. This module fetches (and caches) that repository's
6
+ content, or uses a local checkout directly during development.
7
+
8
+ Resolution order:
9
+ 1. ``MAT_DATA_LOCAL_PATH`` env var, if set: use that directory as-is (no
10
+ network access). Intended for local development against a sibling
11
+ checkout of ``mat-data``, and for CI, which checks out ``mat-data``
12
+ itself and points this at it.
13
+ 2. Cached download for the requested ref under ``MAT_DATA_CACHE_DIR``
14
+ (defaults to ``~/.cache/mat-data-handler``).
15
+ 3. Fresh download via ``MAT_DATA_FETCH_METHOD`` (``http`` tarball download by
16
+ default, stdlib-only; or ``git clone`` if explicitly requested).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import subprocess
23
+ import tarfile
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ DEFAULT_REPO = "ICAMS/mat-data"
28
+ DEFAULT_REF = "main"
29
+
30
+
31
+ def _cache_dir() -> Path:
32
+ return Path(os.environ.get("MAT_DATA_CACHE_DIR", Path.home() / ".cache" / "mat-data-handler"))
33
+
34
+
35
+ def _fetch_http(repo: str, ref: str, dest: Path) -> None:
36
+ """Download and extract the repo's tarball for ``ref`` via GitHub's codeload API."""
37
+ url = f"https://codeload.github.com/{repo}/tar.gz/{ref}"
38
+ dest.parent.mkdir(parents=True, exist_ok=True)
39
+ archive = dest.parent / f"{dest.name}.tar.gz"
40
+ with urllib.request.urlopen(url) as response, open(archive, "wb") as stream:
41
+ stream.write(response.read())
42
+ try:
43
+ with tarfile.open(archive) as tar:
44
+ # GitHub tarballs contain a single top-level "<repo>-<ref>/" directory.
45
+ members = tar.getmembers()
46
+ top = os.path.commonpath([m.name for m in members])
47
+ safe_members = [m for m in members if not (m.name.startswith("/") or ".." in Path(m.name).parts)]
48
+ tar.extractall(dest.parent, members=safe_members) # noqa: S202 -- paths sanitized above
49
+ (dest.parent / top).replace(dest)
50
+ finally:
51
+ archive.unlink(missing_ok=True)
52
+
53
+
54
+ def _fetch_git(repo: str, ref: str, dest: Path) -> None:
55
+ """Shallow-clone the repo at ``ref`` (branch or tag) using the system ``git`` binary."""
56
+ dest.parent.mkdir(parents=True, exist_ok=True)
57
+ subprocess.run(
58
+ ["git", "clone", "--depth", "1", "--branch", ref, f"https://github.com/{repo}.git", str(dest)],
59
+ check=True,
60
+ )
61
+
62
+
63
+ def data_root(repo: str = None, ref: str = None) -> Path:
64
+ """Return a local directory containing entries/, schemas/, mapping_icams_cp.csv."""
65
+ local = os.environ.get("MAT_DATA_LOCAL_PATH")
66
+ if local:
67
+ return Path(local)
68
+
69
+ repo = repo or os.environ.get("MAT_DATA_REPO", DEFAULT_REPO)
70
+ ref = ref or os.environ.get("MAT_DATA_REF", DEFAULT_REF)
71
+ method = os.environ.get("MAT_DATA_FETCH_METHOD", "http")
72
+
73
+ dest = _cache_dir() / repo.replace("/", "__") / ref
74
+ if not dest.is_dir():
75
+ fetch = _fetch_git if method == "git" else _fetch_http
76
+ fetch(repo, ref, dest)
77
+ return dest
78
+
79
+
80
+ def entries_dir(repo: str = None, ref: str = None) -> Path:
81
+ return data_root(repo, ref) / "entries"
82
+
83
+
84
+ def schemas_dir(repo: str = None, ref: str = None) -> Path:
85
+ return data_root(repo, ref) / "schemas"
86
+
87
+
88
+ def mapping_path(repo: str = None, ref: str = None) -> Path:
89
+ return data_root(repo, ref) / "mapping_icams_cp.csv"
@@ -0,0 +1,211 @@
1
+ """Validate individual materials offline and atomically build a collection.
2
+
3
+ Ported from the ``cp-work`` proof of concept (materials/build_database.py),
4
+ adapted to load default data from the installable ``mat-data-handler-data``
5
+ package instead of a fixed repo-relative ``ROOT``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import math
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import sys
17
+ import tempfile
18
+
19
+ import yaml
20
+ from jsonschema import Draft201909Validator
21
+ from referencing import Registry, Resource
22
+
23
+ PLASTIC_MODELS = ("ICAMS CP-UMAT", "DAMASK phenopowerlaw")
24
+
25
+
26
+ class EntryLoader(yaml.SafeLoader):
27
+ def compose_node(self, parent, index):
28
+ if self.check_event(yaml.AliasEvent):
29
+ raise ValueError("YAML aliases are not allowed")
30
+ return super().compose_node(parent, index)
31
+
32
+ def construct_mapping(self, node, deep=False):
33
+ result = {}
34
+ for key_node, value_node in node.value:
35
+ key = self.construct_object(key_node, deep=deep)
36
+ if not isinstance(key, str):
37
+ raise ValueError(f"line {key_node.start_mark.line + 1}: mapping keys must be strings")
38
+ if key in result:
39
+ raise ValueError(f"line {key_node.start_mark.line + 1}: duplicate key {key!r}")
40
+ result[key] = self.construct_object(value_node, deep=deep)
41
+ return result
42
+
43
+
44
+ def json_errors(value, path="$"):
45
+ """Reject YAML-only values and non-finite numbers before schema validation."""
46
+ if isinstance(value, dict):
47
+ for key, child in value.items():
48
+ yield from json_errors(child, f"{path}.{key}")
49
+ elif isinstance(value, list):
50
+ for index, child in enumerate(value):
51
+ yield from json_errors(child, f"{path}[{index}]")
52
+ elif isinstance(value, float) and not math.isfinite(value):
53
+ yield f"{path}: number must be finite"
54
+ elif value is not None and type(value) not in (str, int, float, bool):
55
+ yield f"{path}: unsupported YAML value ({type(value).__name__}); quote text identifiers"
56
+
57
+
58
+ def validator(schema_dir: Path) -> Draft201909Validator:
59
+ resources = []
60
+ for path in sorted(Path(schema_dir).glob("*.json")):
61
+ schema = json.loads(path.read_text(encoding="utf-8"))
62
+ Draft201909Validator.check_schema(schema)
63
+ resources.append((path.resolve().as_uri(), Resource.from_contents(schema)))
64
+ # Registry has no retrieval callback: unresolved references cannot use the network.
65
+ registry = Registry().with_resources(resources)
66
+ uri = (Path(schema_dir) / "material_schema.json").resolve().as_uri()
67
+ return Draft201909Validator({"$ref": uri}, registry=registry)
68
+
69
+
70
+ def schema_messages(error):
71
+ if error.validator == "oneOf" and error.context:
72
+ # Select only diagnostics from the named model's branch. Validation
73
+ # still runs against the complete schema, including shared constraints.
74
+ if isinstance(error.instance, dict):
75
+ model = error.instance.get("plastic_model_name")
76
+ matching = [
77
+ index for index, branch in enumerate(error.validator_value)
78
+ if branch.get("properties", {}).get("plastic_model_name", {}).get("const") == model
79
+ and model in PLASTIC_MODELS
80
+ ]
81
+ if len(matching) == 1:
82
+ for child in error.context:
83
+ if child.schema_path and child.schema_path[0] == matching[0]:
84
+ yield from schema_messages(child)
85
+ return
86
+ yield f"{error.json_path}: does not match an allowed schema alternative"
87
+ for child in error.context:
88
+ yield from schema_messages(child)
89
+ else:
90
+ yield f"{error.json_path}: {error.message}"
91
+
92
+
93
+ def collect(entries: Path, schemas: Path):
94
+ """Validate every YAML entry in ``entries`` against ``schemas``.
95
+
96
+ Returns ``(database, errors)``. ``database`` maps material key (filename
97
+ stem) to the parsed entry; ``errors`` lists human-readable problems.
98
+ """
99
+ entries, schemas = Path(entries), Path(schemas)
100
+ check = validator(schemas)
101
+ files = sorted(p for p in entries.iterdir() if p.is_file() and p.suffix in (".yaml", ".yml"))
102
+ if not files:
103
+ raise ValueError(f"{entries}: no YAML entries found")
104
+ database, errors = {}, []
105
+ for path in files:
106
+ if not re.fullmatch(r"[a-z0-9_]+", path.stem):
107
+ errors.append(f"{path}: filename stem must contain lowercase letters, digits, or underscores")
108
+ continue
109
+ if path.stem in database:
110
+ errors.append(f"{path}: duplicate material key {path.stem!r}")
111
+ continue
112
+ try:
113
+ entry = yaml.load(path.read_text(encoding="utf-8"), Loader=EntryLoader)
114
+ problems = list(json_errors(entry))
115
+ if not problems and isinstance(entry, dict):
116
+ model_object = entry.get("constitutive_model", {})
117
+ if isinstance(model_object, dict):
118
+ model = model_object.get("plastic_model_name")
119
+ if model not in PLASTIC_MODELS:
120
+ problems = [
121
+ "$.constitutive_model.plastic_model_name: "
122
+ f"missing or unsupported plastic model name {model!r}; "
123
+ f"expected one of {PLASTIC_MODELS!r}"
124
+ ]
125
+ if not problems:
126
+ problems = [
127
+ message
128
+ for error in check.iter_errors(entry)
129
+ for message in schema_messages(error)
130
+ ]
131
+ errors.extend(f"{path}: {problem}" for problem in problems)
132
+ database[path.stem] = entry
133
+ except (ValueError, yaml.YAMLError) as error:
134
+ errors.append(f"{path}: {error}")
135
+ return database, errors
136
+
137
+
138
+ def validate_only(entries: Path, schemas: Path) -> int:
139
+ """Validate entries and return the count of valid materials, raising on error."""
140
+ database, errors = collect(entries, schemas)
141
+ if errors:
142
+ raise ValueError("\n".join(errors))
143
+ return len(database)
144
+
145
+
146
+ def build_database(entries: Path, schemas: Path, output_format: str = "yaml") -> str:
147
+ """Validate entries and return the serialized combined database as text."""
148
+ database, errors = collect(entries, schemas)
149
+ if errors:
150
+ raise ValueError("\n".join(errors))
151
+ if output_format == "json":
152
+ return json.dumps(database, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False) + "\n"
153
+ return yaml.safe_dump(database, sort_keys=True, allow_unicode=True)
154
+
155
+
156
+ def main(argv=None):
157
+ from .data_source import entries_dir as default_entries_dir
158
+ from .data_source import schemas_dir as default_schemas_dir
159
+
160
+ parser = argparse.ArgumentParser(description=__doc__)
161
+ parser.add_argument("--entries", type=Path, help="Defaults to the mat-data repo's entries/ (fetched/cached)")
162
+ parser.add_argument("--schemas", type=Path, help="Defaults to the mat-data repo's schemas/ (fetched/cached)")
163
+ parser.add_argument("--output", "-o", type=Path, required=True, help="Destination (.yaml/.yml or .json)")
164
+ parser.add_argument("--format", choices=("yaml", "json"), help="Defaults to output extension, or YAML")
165
+ mode = parser.add_mutually_exclusive_group()
166
+ mode.add_argument("--check", action="store_true", help="Fail if the output is missing or stale; write nothing")
167
+ mode.add_argument("--validate-only", action="store_true", help="Validate entries without building")
168
+ args = parser.parse_args(argv)
169
+ try:
170
+ entries = args.entries or default_entries_dir()
171
+ schemas = args.schemas or default_schemas_dir()
172
+
173
+ output = args.output
174
+ output_format = args.format or ("json" if output.suffix == ".json" else "yaml")
175
+ if output.resolve().parent in (Path(entries).resolve(), Path(schemas).resolve()):
176
+ raise ValueError("Output must be outside the entries and schemas directories")
177
+
178
+ if args.validate_only:
179
+ count = validate_only(entries, schemas)
180
+ print(f"Validated {count} materials")
181
+ return 0
182
+
183
+ content = build_database(entries, schemas, output_format)
184
+ data = content.encode("utf-8")
185
+ if args.check:
186
+ if not output.is_file() or output.read_bytes() != data:
187
+ print(f"{output}: missing or stale; rebuild without --check", file=sys.stderr)
188
+ return 1
189
+ print(f"{output}: up to date")
190
+ return 0
191
+ output.parent.mkdir(parents=True, exist_ok=True)
192
+ temporary = None
193
+ try:
194
+ with tempfile.NamedTemporaryFile(dir=output.parent, delete=False) as stream:
195
+ temporary = Path(stream.name)
196
+ stream.write(data)
197
+ stream.flush()
198
+ os.fsync(stream.fileno())
199
+ temporary.replace(output)
200
+ finally:
201
+ if temporary is not None:
202
+ temporary.unlink(missing_ok=True)
203
+ print(f"Wrote database to {output}")
204
+ return 0
205
+ except Exception as error:
206
+ print(f"Build failed: {error}", file=sys.stderr)
207
+ return 1
208
+
209
+
210
+ if __name__ == "__main__":
211
+ sys.exit(main())
@@ -0,0 +1,315 @@
1
+ """Export schema-valid material entries to shared Abaqus PROPS(9:) include files.
2
+
3
+ Ported from ``cp-work``'s ``materials/extract_params.py``. Business logic for
4
+ which parameters are active and how they map to Abaqus PROPS positions is
5
+ unchanged; only default data locations moved to the ``mat-data-handler-data``
6
+ package.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import ast
13
+ import csv
14
+ import io
15
+ import json
16
+ import math
17
+ from pathlib import Path
18
+ import re
19
+ import sys
20
+
21
+ import yaml
22
+
23
+ from .database import EntryLoader, json_errors, schema_messages, validator
24
+
25
+ FLAG_LAYOUT = dict(zip((
26
+ "flag_isotropic_hardening", "flag_back_stress", "flag_gradient_plasticity",
27
+ "flag_int", "flag_super_alloy", "flag_trip", "flag_thermal",
28
+ ), range(7)))
29
+ BASE = ["C11", "C12", "C44", "number_slip_systems", "reference_shear_rate",
30
+ "stress_exponent", "initial_critical_resolved_shear_stress"]
31
+ ISO = ["saturated_slip_resistance", "reference_hardening_rate", "self_hardening",
32
+ "cross_hardening", "hardening_exponent"]
33
+ KIN = ["nslip_kinematic_hardening", "kinematic_hardening_a1", "kinematic_hardening_b1",
34
+ "ohno_wang_exponent", "chaboche_parameter_a2", "chaboche_parameter_b2",
35
+ "chaboche_parameter_a3", "chaboche_parameter_b3"]
36
+ GRAD = ["flag_octree", "C_taui", "L_size", "I_gnd_iso", "I_gnd_kin", "C_unit", "B_lattice"]
37
+ THERM = ["activation_energy", "c11_tcoeff", "c12_tcoeff", "c44_tcoeff",
38
+ "kinematic_hardening_a1_tcoeff", "crss0_coef_tcoeff"]
39
+
40
+
41
+ def load_document(path: Path):
42
+ def unique_object(pairs):
43
+ result = {}
44
+ for key, value in pairs:
45
+ if key in result:
46
+ raise ValueError(f"duplicate key: {key}")
47
+ result[key] = value
48
+ return result
49
+
50
+ path = Path(path)
51
+ text = path.read_text(encoding="utf-8")
52
+ # JSON's exponent syntax (e.g. 1e-06) differs from PyYAML's resolver.
53
+ value = (json.loads(text, object_pairs_hook=unique_object) if path.suffix.lower() == ".json"
54
+ else yaml.load(text, Loader=EntryLoader))
55
+ errors = list(json_errors(value))
56
+ if errors:
57
+ raise ValueError("; ".join(errors))
58
+ return value
59
+
60
+
61
+ def parse_mapping(path: Path):
62
+ with Path(path).open(encoding="utf-8-sig", newline="") as stream:
63
+ rows = [{k.strip(): v.strip() for k, v in row.items()} for row in csv.DictReader(stream, delimiter=";")]
64
+ result = {}
65
+ for row in rows:
66
+ key = row[next(k for k in row if k.startswith("json_key"))]
67
+ if key in result:
68
+ raise ValueError(f"duplicate mapping key: {key}")
69
+ result[key] = row
70
+ return result
71
+
72
+
73
+ def condition(expression, env, provided=False):
74
+ expression = expression.strip()
75
+ if expression in ("always", ""):
76
+ return True
77
+ if expression == "never":
78
+ return False
79
+ if expression == "provided":
80
+ return provided
81
+ node = ast.parse(expression, mode="eval")
82
+ allowed = (ast.Expression, ast.Compare, ast.Name, ast.Load, ast.Constant,
83
+ ast.Eq, ast.NotEq, ast.Gt, ast.GtE, ast.Lt, ast.LtE,
84
+ ast.BoolOp, ast.And, ast.Or, ast.UnaryOp, ast.Not)
85
+ if any(not isinstance(n, allowed) for n in ast.walk(node)):
86
+ raise ValueError(f"unsupported mapping condition: {expression}")
87
+ return bool(eval(compile(node, "<mapping condition>", "eval"), {"__builtins__": {}}, env))
88
+
89
+
90
+ def scalar(value, key):
91
+ while isinstance(value, list):
92
+ if len(value) != 1:
93
+ raise ValueError(f"{key}: current Fortran reader supports only one family / coefficient")
94
+ value = value[0]
95
+ if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(value):
96
+ raise ValueError(f"{key}: expected a finite number")
97
+ return value
98
+
99
+
100
+ def export_entry(entry, mapping, schemas: Path):
101
+ model = entry.get("constitutive_model", {}) if isinstance(entry, dict) else {}
102
+ if not isinstance(model, dict) or model.get("plastic_model_name") != "ICAMS CP-UMAT":
103
+ raise ValueError("CP-UMAT export requires plastic_model_name: ICAMS CP-UMAT")
104
+ errors = [m for e in validator(schemas).iter_errors(entry) for m in schema_messages(e)]
105
+ if errors:
106
+ raise ValueError("\n".join(errors))
107
+ plastic = model["plastic_parameters"]
108
+ elastic = model["elastic_parameters"]
109
+ units = model["units"]
110
+ records, notes = [], []
111
+
112
+ def raw(key):
113
+ if key == "space_group_number":
114
+ if key in plastic and plastic[key] != entry[key]:
115
+ raise ValueError("conflicting space_group_number values")
116
+ return entry[key]
117
+ return elastic.get(key) if key in elastic else plastic.get(key)
118
+
119
+ flags = {}
120
+ packed = plastic.get("super_flag")
121
+ if packed is not None and not 0 <= packed <= 0x7FFFFFFF:
122
+ raise ValueError("super_flag exceeds signed 32-bit range")
123
+ for key, pos in FLAG_LAYOUT.items():
124
+ if key in plastic:
125
+ flags[key] = plastic[key]
126
+ if packed is not None and ((packed >> (4 * pos)) & 15) != flags[key]:
127
+ raise ValueError(f"super_flag mismatch for {key}")
128
+ elif packed is not None:
129
+ flags[key] = (packed >> (4 * pos)) & 15
130
+ notes.append(f"{key}: decoded from super_flag = {flags[key]}")
131
+ else:
132
+ flags[key] = int(mapping[key]["default_value"])
133
+ notes.append(f"{key}: mapping default = {flags[key]}")
134
+ maximum = 3 if key == "flag_back_stress" else 1
135
+ if not 0 <= flags[key] <= maximum:
136
+ raise ValueError(f"{key}: unsupported mode {flags[key]}")
137
+ super_flag = sum(value << (4 * FLAG_LAYOUT[key]) for key, value in flags.items())
138
+ if packed is not None and packed != super_flag:
139
+ raise ValueError("super_flag contains unsupported spare bits")
140
+ env = dict(zip(("iso_mode", "kin_mode", "grad_mode", "int_mode", "super_mode", "trip_mode", "thermal_mode"), flags.values()))
141
+ if env["super_mode"]:
142
+ raise ValueError("superalloy export unavailable: the schema/mapping do not represent all 12 values read by the UMAT")
143
+ if env["trip_mode"] or env["int_mode"]:
144
+ raise ValueError("TRIP/internal-stress export requires additional constitutive compatibility checks; currently unsupported")
145
+ if scalar(plastic.get("number_slip_families", 1), "number_slip_families") != 1:
146
+ raise ValueError("current Fortran reader supports one slip family only")
147
+ if any(key in elastic for key in ("C33", "C66", "C13")):
148
+ raise ValueError("current Fortran reader consumes only C11, C12, C44; additional elastic constants cannot be exported")
149
+ for key, value in plastic.items():
150
+ if isinstance(value, list):
151
+ scalar(value, key)
152
+ if env["thermal_mode"]:
153
+ if plastic.get("temperature_polynomial_degree", 1) != 1:
154
+ raise ValueError("current Fortran reader supports linear thermal slopes only")
155
+ extra = [k for k in plastic if k.endswith("_tcoeff") and k not in THERM]
156
+ if extra:
157
+ raise ValueError(f"thermal coefficients not read by CP-UMAT: {extra}")
158
+
159
+ def append(key, value, source, unit="-"):
160
+ records.append(dict(props_position=9 + len(records), key=key, value=value, source=source, unit=unit))
161
+
162
+ def mapped(key):
163
+ row = mapping[key]
164
+ value = raw(key)
165
+ provided = value is not None
166
+ if not condition(row["active_if"], env, provided):
167
+ raise ValueError(f"mapping disables {key}, but the Fortran reader requires its slot")
168
+ source = "entry"
169
+ if not provided:
170
+ if condition(row["required_if"], env, provided):
171
+ raise ValueError(f"missing required active parameter: {key}")
172
+ value = float(row["default_value"].replace(",", "."))
173
+ source = "mapping default"
174
+ value = scalar(value, key)
175
+ if row["fortran_type"].startswith("integer"):
176
+ if value != int(value):
177
+ raise ValueError(f"{key}: expected integer")
178
+ value = int(value)
179
+ elif not row["fortran_type"].startswith("real"):
180
+ raise ValueError(f"{key}: unsupported mapping type")
181
+ if key in ("number_slip_systems", "nslip_kinematic_hardening") and not 1 <= value <= 60:
182
+ raise ValueError(f"{key}: must be within current Nslp_mx=60")
183
+ if key in ("I_gnd_iso", "I_gnd_kin") and value not in (0, 1):
184
+ raise ValueError(f"{key}: expected 0 or 1")
185
+ unit = row["unit"]
186
+ if provided and unit.startswith("MPa"):
187
+ category = "Stiffness" if key in elastic or key in ("c11_tcoeff", "c12_tcoeff", "c44_tcoeff") else "Stress"
188
+ if units[category] == "Pa":
189
+ value *= 1e-6
190
+ source += " (Pa converted to MPa)"
191
+ if source == "mapping default":
192
+ notes.append(f"{key}: mapping default = {value} {unit}")
193
+ append(key, value, source, unit)
194
+
195
+ mapped("space_group_number")
196
+ append("super_flag", super_flag, "packed flags")
197
+ for key in BASE:
198
+ mapped(key)
199
+ if env["iso_mode"]:
200
+ for key in ISO:
201
+ mapped(key)
202
+ if env["kin_mode"]:
203
+ for key in KIN:
204
+ mapped(key)
205
+ if env["grad_mode"]:
206
+ for key in GRAD:
207
+ mapped(key)
208
+ # Not yet in mapping/schema; retain the explicit reader initialization.
209
+ append("CD_smooth", 3e-15, "src/mod_material.f default")
210
+ notes.append("CD_smooth: reader default = 3e-15 (not configurable in current schema/mapping)")
211
+ if env["thermal_mode"]:
212
+ for key in THERM:
213
+ mapped(key)
214
+ return records, notes
215
+
216
+
217
+ def render_outputs(name, entry, records, notes, source):
218
+ values = [str(r["value"]) if isinstance(r["value"], int) else format(r["value"], ".17g") for r in records]
219
+ inc_name = f"{name}_inp_{len(values)}p.inc"
220
+ lines = [", ".join(values[i:i + 8]) + ("," if i + 8 < len(values) else "") for i in range(0, len(values), 8)]
221
+ stream = io.StringIO()
222
+ writer = csv.DictWriter(stream, fieldnames=list(records[0]))
223
+ writer.writeheader()
224
+ writer.writerows(records)
225
+ manifest = [f"Source: {source}", f"Include: {inc_name}", f"Include count: {len(values)}",
226
+ f"Total Abaqus constant count: {len(values) + 8}",
227
+ "PROPS(1:8) supplied by caller: material selector, three Euler angles (rad), four spare values.",
228
+ "", "Abaqus usage (replace Euler angles for each grain):",
229
+ f"*User Material, constants={len(values) + 8}",
230
+ f'{entry["material_id"]}, 0., 0., 0., 0., 0., 0., 0.,',
231
+ f"*Include, input={inc_name}", "", "Positions:"]
232
+ manifest += [f'PROPS({r["props_position"]}): {r["key"]} = {r["value"]} [{r["unit"]}] ({r["source"]})' for r in records]
233
+ manifest += ["", "Defaults and notes:", *notes]
234
+ return {inc_name: "\n".join(lines) + "\n", f"{name}_props_flat.csv": stream.getvalue(),
235
+ f"{name}_props_manifest.txt": "\n".join(manifest) + "\n"}
236
+
237
+
238
+ def export_material(name: str, entry, mapping, schemas: Path):
239
+ """Export one already-loaded material entry. Returns ``(records, notes)``."""
240
+ return export_entry(entry, mapping, schemas)
241
+
242
+
243
+ def _default_data_paths():
244
+ from .data_source import entries_dir, mapping_path, schemas_dir
245
+
246
+ return entries_dir(), schemas_dir(), mapping_path()
247
+
248
+
249
+ def main(argv=None):
250
+ parser = argparse.ArgumentParser(description=__doc__)
251
+ parser.add_argument("material", help="Material key or 'all'")
252
+ parser.add_argument("--input", "--yaml_file", "--json_file", dest="input", type=Path,
253
+ help="Entry directory, single entry, or combined YAML/JSON database; "
254
+ "defaults to the mat-data repo's entries/ (fetched/cached)")
255
+ parser.add_argument("--mapping-file", "--mapping_file", dest="mapping_file", type=Path,
256
+ help="Defaults to the mat-data repo's mapping.csv (fetched/cached)")
257
+ parser.add_argument("--schemas", type=Path, help="Defaults to the mat-data repo's schemas/ (fetched/cached)")
258
+ parser.add_argument("--outdir", type=Path, default=Path("."))
259
+ parser.add_argument("--values-per-line", type=int, choices=[8], default=8)
260
+ args = parser.parse_args(argv)
261
+ try:
262
+ if args.input is None or args.mapping_file is None or args.schemas is None:
263
+ default_entries, default_schemas, default_mapping = _default_data_paths()
264
+ input_path = args.input or default_entries
265
+ mapping_file = args.mapping_file or default_mapping
266
+ schemas = args.schemas or default_schemas
267
+ else:
268
+ input_path, mapping_file, schemas = args.input, args.mapping_file, args.schemas
269
+
270
+ mapping = parse_mapping(mapping_file)
271
+ input_path = Path(input_path)
272
+ if input_path.is_dir():
273
+ files = sorted(p for p in input_path.iterdir() if p.suffix in (".yaml", ".yml") and p.is_file())
274
+ selected = files if args.material == "all" else [p for p in files if p.stem == args.material]
275
+ entries = {}
276
+ for path in selected:
277
+ if path.stem in entries:
278
+ raise ValueError(f"duplicate material key: {path.stem}")
279
+ entries[path.stem] = load_document(path)
280
+ else:
281
+ data = load_document(input_path)
282
+ if not isinstance(data, dict):
283
+ raise ValueError("input must be a material object or keyed database")
284
+ entries = {input_path.stem: data} if "constitutive_model" in data else data
285
+ if args.material != "all":
286
+ entries = {k: v for k, v in entries.items() if k == args.material}
287
+ if not entries:
288
+ raise ValueError(f"no matching materials: {args.material}")
289
+
290
+ outputs, errors = {}, []
291
+ for name, entry in sorted(entries.items()):
292
+ try:
293
+ if not isinstance(name, str) or not re.fullmatch("[a-z0-9_]+", name):
294
+ raise ValueError("invalid material key")
295
+ records, notes = export_entry(entry, mapping, schemas)
296
+ outputs.update(render_outputs(name, entry, records, notes, input_path))
297
+ except (ValueError, KeyError) as error:
298
+ errors.append(f"{name}: {error}")
299
+ if errors:
300
+ raise ValueError("\n".join(errors))
301
+
302
+ # Validate the whole selection before touching any output files.
303
+ args.outdir.mkdir(parents=True, exist_ok=True)
304
+ for name, content in outputs.items():
305
+ path = args.outdir / name
306
+ path.write_text(content, encoding="utf-8")
307
+ print(f"Wrote {path}")
308
+ return 0
309
+ except Exception as error:
310
+ print(f"Export failed: {error}", file=sys.stderr)
311
+ return 1
312
+
313
+
314
+ if __name__ == "__main__":
315
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: mat-data-handler
3
+ Version: 0.1.0
4
+ Summary: Validate, combine, and export crystal-plasticity material parameter sets for ICAMS CP-UMAT.
5
+ Project-URL: Homepage, https://github.com/ICAMS/mat-data-handler
6
+ Project-URL: Repository, https://github.com/ICAMS/mat-data-handler
7
+ Author: ICAMS, Ruhr University Bochum
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: jsonschema>=4.18
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: referencing>=0.30
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mat-data-handler – Organizing Material Data for Crystal Plasticity
22
+
23
+ Plain Python package to validate, combine, and export crystal-plasticity material parameter sets. The material data itself (YAML
24
+ entries, JSON schemas, PROPS mapping table) is retrieved from the
25
+ [mat-data](https://github.com/ICAMS/mat-data) repository and fetched over
26
+ the network (and cached locally) at runtime.
27
+ Currently only support for the crystal plasticity model [ICAMS CP-UMAT](https://github.com/ICAMS/Crystal_Plasticity_UMAT.git) is provided. Use the `mat_extract_params` command to extract the require .inc files that contains the values for PROPS[9:] read by the ICAMS CP-UMAT.
28
+
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install mat-data-handler
34
+ ```
35
+
36
+ First use of the CLI/API fetches the pinned `mat-data` ref (default: `main`)
37
+ and caches it under `~/.cache/mat-data-handler`. For offline use or local
38
+ development against a sibling checkout, set:
39
+
40
+ ```bash
41
+ export MAT_DATA_LOCAL_PATH=/path/to/mat-data # skips network access entirely
42
+ ```
43
+
44
+ Other environment variables: `MAT_DATA_REPO` (default `ICAMS/mat-data`),
45
+ `MAT_DATA_REF` (default `main`; pin to a release tag for reproducibility),
46
+ `MAT_DATA_FETCH_METHOD` (`http` default, stdlib-only tarball download; or
47
+ `git`, which shells out to the `git` binary), `MAT_DATA_CACHE_DIR`.
48
+
49
+ ## API
50
+
51
+ - `mat_data_handler.data_source.{entries_dir, schemas_dir, mapping_path}()` —
52
+ resolve (fetching/caching as needed) local paths to the `mat-data` content.
53
+ - `mat_data_handler.database.build_database(entries, schemas, output_format="yaml")`
54
+ — validate all entries and return the combined database as text.
55
+ - `mat_data_handler.database.validate_only(entries, schemas)` — validate only,
56
+ returns the number of valid materials.
57
+ - `mat_data_handler.extract.export_entry(entry, mapping, schemas)` — export
58
+ one loaded material entry to Abaqus `PROPS` records.
59
+ - `mat_data_handler.extract.load_document(path)` / `parse_mapping(path)` —
60
+ helpers to load a YAML/JSON entry or the `mapping.csv` table.
61
+
62
+ ## CLI
63
+
64
+ ```bash
65
+ mat-build-database --output build/materials.yaml # generate combined database
66
+ mat-extract-params copper_generic --outdir build/includes
67
+ mat-extract-params all --outdir build/includes
68
+ ```
69
+
70
+ Both commands default to fetching data from `mat-data`; pass
71
+ `--entries`/`--schemas`/`--mapping-file` to point at a different data source
72
+ (e.g. a local checkout) directly.
73
+
74
+ ## Contributing
75
+
76
+ See [CONTRIBUTING.md](CONTRIBUTING.md). For adding/updating material data,
77
+ see [mat-data](https://github.com/ICAMS/mat-data) instead — this repo has no
78
+ data to edit.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ pip install -e ".[test]"
84
+ export MAT_DATA_LOCAL_PATH=../mat-data # or wherever your mat-data checkout lives
85
+ pytest
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT — see [LICENSE](LICENSE). (Material data in `mat-data` is licensed
91
+ separately, under CC-BY-4.0.)
@@ -0,0 +1,10 @@
1
+ mat_data_handler/__init__.py,sha256=9yqqaxUpyX0j3vKlXjHSl1Eo0OM1pHDfbl-NL6eGwWE,650
2
+ mat_data_handler/data_source.py,sha256=FBnBo_Ce4rBI6h1pUbeaxpniEI_E_9nw7gBEGAzBQGg,3492
3
+ mat_data_handler/database.py,sha256=H0i2b9uf3eQSNuxMzE0f6chFzbrZ42gIoSPtrTz7p7A,9195
4
+ mat_data_handler/extract.py,sha256=phIQkgaSSbSGvPf1jaMDww_F4Q8PZwmRLIM1wOcGM3s,14753
5
+ mat_data_handler/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ mat_data_handler-0.1.0.dist-info/METADATA,sha256=83XrDlnWkXKoDZSxs10bY-P7rfbN14hbzrYz2kkYwaY,3582
7
+ mat_data_handler-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ mat_data_handler-0.1.0.dist-info/entry_points.txt,sha256=tuzfLeF3OaheKoLY_eOUQyeYf0s4mvS1cIm291WmZ_o,121
9
+ mat_data_handler-0.1.0.dist-info/licenses/LICENSE,sha256=L24ZmS9xS9o1zVRivrLfoMM2rshC5aU21Pz9_2fcdi0,1086
10
+ mat_data_handler-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mat-build-database = mat_data_handler.database:main
3
+ mat-extract-params = mat_data_handler.extract:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ICAMS, Ruhr University Bochum
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.