cpdatakit 0.1.1__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.
cpdatakit/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """CPDataKit public API."""
2
+
3
+ from ._version import __version__
4
+ from .io import load_dataset
5
+ from .normalization import FieldMapping, normalize_dataset
6
+ from .statistics import summarize_dataset
7
+ from .validation import validate_dataset
8
+
9
+ __all__ = [
10
+ "FieldMapping",
11
+ "__version__",
12
+ "load_dataset",
13
+ "normalize_dataset",
14
+ "summarize_dataset",
15
+ "validate_dataset",
16
+ ]
cpdatakit/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version kept in one import-safe location."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,16 @@
1
+ """Extension interfaces; no solver-specific adapter is bundled in v0.1.x."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+ from ..model import Dataset
9
+
10
+
11
+ class DatasetAdapter(ABC):
12
+ """Contract for optional, independently licensed source adapters."""
13
+
14
+ @abstractmethod
15
+ def load(self, path: Path) -> Dataset:
16
+ """Translate an explicit external representation into a Dataset."""
cpdatakit/cli.py ADDED
@@ -0,0 +1,145 @@
1
+ """Command-line interface for CPDataKit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import matplotlib.pyplot as plt
12
+
13
+ from . import __version__
14
+ from .exceptions import CPDataKitError
15
+ from .io import load_dataset, write_hdf5
16
+ from .plotting import (
17
+ plot_counts,
18
+ plot_field2d,
19
+ plot_histogram,
20
+ plot_stress_strain,
21
+ save_figure,
22
+ )
23
+ from .schema import load_schema
24
+ from .statistics import summarize_dataset
25
+ from .validation import validate_dataset
26
+
27
+
28
+ def _common(parser: argparse.ArgumentParser) -> None:
29
+ parser.add_argument("data", type=Path, help="Input CSV, JSON records, or CPDataKit HDF5")
30
+ parser.add_argument("--schema", required=True, help="Built-in profile or JSON schema path")
31
+
32
+
33
+ def _parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="cpdatakit",
36
+ description="Validate and process declared crystal-plasticity data structures.",
37
+ )
38
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
39
+ parser.add_argument(
40
+ "--debug", action="store_true", help="Show tracebacks for unexpected failures"
41
+ )
42
+ commands = parser.add_subparsers(dest="command", required=True)
43
+ validate = commands.add_parser("validate", help="Validate schema conformance")
44
+ _common(validate)
45
+ validate.add_argument("--json-output", type=Path, help="Write the validation report as JSON")
46
+ validate.add_argument("--force", action="store_true", help="Replace an existing report")
47
+ summary = commands.add_parser("summary", help="Print a descriptive JSON summary")
48
+ _common(summary)
49
+ summary.add_argument("--json-output", type=Path, help="Write the summary as JSON")
50
+ summary.add_argument("--force", action="store_true", help="Replace an existing report")
51
+ convert = commands.add_parser("convert", help="Convert to CPDataKit HDF5")
52
+ _common(convert)
53
+ convert.add_argument("--output", required=True, type=Path)
54
+ convert.add_argument("--source-description")
55
+ convert.add_argument("--force", action="store_true")
56
+ plot = commands.add_parser("plot", help="Create a PNG or SVG plot")
57
+ _common(plot)
58
+ plot.add_argument(
59
+ "--kind",
60
+ required=True,
61
+ choices=["stress-strain", "histogram", "grain-count", "phase-count", "field2d"],
62
+ )
63
+ plot.add_argument("--field", help="Declared numeric field for histogram")
64
+ plot.add_argument("--output", required=True, type=Path)
65
+ plot.add_argument("--force", action="store_true")
66
+ return parser
67
+
68
+
69
+ def _write_json(payload: dict[str, Any], target: Path | None, force: bool) -> None:
70
+ rendered = json.dumps(payload, indent=2, sort_keys=True, allow_nan=False)
71
+ if target is None:
72
+ print(rendered)
73
+ return
74
+ if target.exists() and not force:
75
+ raise CPDataKitError(f"Output already exists: {target}; pass --force to replace it")
76
+ target.parent.mkdir(parents=True, exist_ok=True)
77
+ target.write_text(rendered + "\n", encoding="utf-8")
78
+
79
+
80
+ def _run(args: argparse.Namespace) -> int:
81
+ schema = load_schema(args.schema)
82
+ dataset = load_dataset(args.data)
83
+ result = validate_dataset(dataset, schema)
84
+ if args.command == "validate":
85
+ _write_json(result.to_dict(), args.json_output, args.force)
86
+ return 0 if result.valid else 1
87
+ if args.command == "summary":
88
+ _write_json(
89
+ summarize_dataset(dataset, schema, validation=result), args.json_output, args.force
90
+ )
91
+ return 0 if result.valid else 1
92
+ if args.command == "convert":
93
+ if not result.valid:
94
+ print(json.dumps(result.to_dict(), indent=2), file=sys.stderr)
95
+ return 1
96
+ write_hdf5(
97
+ dataset,
98
+ args.output,
99
+ schema,
100
+ result,
101
+ source_description=args.source_description,
102
+ operation_log=["load", "validate", "convert"],
103
+ force=args.force,
104
+ )
105
+ print(args.output)
106
+ return 0
107
+ if not result.valid:
108
+ print(json.dumps(result.to_dict(), indent=2), file=sys.stderr)
109
+ return 1
110
+ if args.kind == "stress-strain":
111
+ fig, _ = plot_stress_strain(dataset, schema)
112
+ elif args.kind == "histogram":
113
+ if not args.field:
114
+ raise CPDataKitError("--field is required for histogram")
115
+ fig, _ = plot_histogram(dataset, schema, args.field)
116
+ elif args.kind == "grain-count":
117
+ fig, _ = plot_counts(dataset, schema, "grain_id")
118
+ elif args.kind == "phase-count":
119
+ fig, _ = plot_counts(dataset, schema, "phase_id")
120
+ else:
121
+ fig, _ = plot_field2d(dataset, schema)
122
+ try:
123
+ save_figure(fig, args.output, force=args.force)
124
+ finally:
125
+ plt.close(fig)
126
+ print(args.output)
127
+ return 0
128
+
129
+
130
+ def main(argv: list[str] | None = None) -> int:
131
+ """Run the CLI and return a process exit status."""
132
+ parser = _parser()
133
+ args = parser.parse_args(argv)
134
+ try:
135
+ return _run(args)
136
+ except CPDataKitError as exc:
137
+ if args.debug:
138
+ raise
139
+ parser.print_usage(sys.stderr)
140
+ print(f"{parser.prog}: error: {exc}", file=sys.stderr)
141
+ return 2
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())
@@ -0,0 +1,21 @@
1
+ """Public exception hierarchy for CPDataKit."""
2
+
3
+
4
+ class CPDataKitError(Exception):
5
+ """Base class for expected CPDataKit failures."""
6
+
7
+
8
+ class DataReadError(CPDataKitError):
9
+ """Raised when an input cannot be read as a supported dataset."""
10
+
11
+
12
+ class SchemaError(CPDataKitError):
13
+ """Raised when a schema is missing, malformed, or unsupported."""
14
+
15
+
16
+ class NormalizationError(CPDataKitError):
17
+ """Raised when an explicit mapping cannot be applied safely."""
18
+
19
+
20
+ class OutputExistsError(CPDataKitError):
21
+ """Raised when an operation would overwrite a file without consent."""
@@ -0,0 +1,166 @@
1
+ """CSV, JSON records, and CPDataKit HDF5 I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import h5py
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from ..exceptions import DataReadError, OutputExistsError
14
+ from ..model import Dataset, ValidationResult
15
+ from ..provenance import build_provenance
16
+ from ..schema import ProfileSchema
17
+
18
+ _SUPPORTED = {".csv", ".json", ".h5", ".hdf5"}
19
+
20
+
21
+ def _ensure_readable(path: Path) -> None:
22
+ if not path.exists():
23
+ raise DataReadError(f"Input path does not exist: {path}")
24
+ if not path.is_file():
25
+ raise DataReadError(f"Input path is not a file: {path}")
26
+ if path.stat().st_size == 0:
27
+ raise DataReadError(f"Input file is empty: {path}")
28
+ if path.suffix.lower() not in _SUPPORTED:
29
+ raise DataReadError(
30
+ f"Unsupported extension {path.suffix!r}; supported: {', '.join(sorted(_SUPPORTED))}"
31
+ )
32
+
33
+
34
+ def _read_hdf5(path: Path) -> Dataset:
35
+ try:
36
+ with h5py.File(path, "r") as handle:
37
+ if handle.attrs.get("format") != "CPDataKit":
38
+ raise DataReadError("HDF5 is not a CPDataKit file (missing format marker)")
39
+ if "data" not in handle or not isinstance(handle["data"], h5py.Group):
40
+ raise DataReadError("CPDataKit HDF5 is missing the /data group")
41
+ columns: dict[str, Any] = {}
42
+ record_count: int | None = None
43
+ for name, item in handle["data"].items():
44
+ if not isinstance(item, h5py.Dataset):
45
+ continue
46
+ values = item[()]
47
+ if values.ndim == 0:
48
+ raise DataReadError(f"CPDataKit HDF5 field {name!r} must contain records")
49
+ if values.dtype.kind in {"S", "O"}:
50
+ values = np.asarray(
51
+ [v.decode("utf-8") if isinstance(v, bytes) else v for v in values]
52
+ )
53
+ if record_count is None:
54
+ record_count = len(values)
55
+ elif len(values) != record_count:
56
+ raise DataReadError("CPDataKit HDF5 fields have inconsistent record counts")
57
+ columns[name] = list(values) if values.ndim > 1 else values
58
+ if not columns:
59
+ raise DataReadError("CPDataKit HDF5 /data group contains no fields")
60
+ if record_count == 0:
61
+ raise DataReadError("CPDataKit HDF5 contains no records")
62
+ metadata = {
63
+ "profile": str(handle.attrs.get("profile", "")),
64
+ "schema_version": str(handle.attrs.get("schema_version", "")),
65
+ "units": json.loads(str(handle.attrs.get("units_json", "{}"))),
66
+ "field_mapping": json.loads(str(handle.attrs.get("field_mapping_json", "{}"))),
67
+ "provenance": json.loads(str(handle.attrs.get("provenance_json", "{}"))),
68
+ "validation_summary": json.loads(
69
+ str(handle.attrs.get("validation_summary_json", "{}"))
70
+ ),
71
+ }
72
+ frame = pd.DataFrame(columns)
73
+ except DataReadError:
74
+ raise
75
+ except (OSError, ValueError, KeyError, TypeError, UnicodeError) as exc:
76
+ raise DataReadError(f"Cannot read CPDataKit HDF5 {path}: {exc}") from exc
77
+ return Dataset(frame, metadata, path)
78
+
79
+
80
+ def load_dataset(path: str | Path) -> Dataset:
81
+ """Load CSV, JSON records, or CPDataKit HDF5 from a filesystem path."""
82
+ input_path = Path(path)
83
+ _ensure_readable(input_path)
84
+ suffix = input_path.suffix.lower()
85
+ try:
86
+ if suffix == ".csv":
87
+ frame = pd.read_csv(input_path)
88
+ if frame.empty:
89
+ raise DataReadError(f"CSV has no records: {input_path}")
90
+ return Dataset(frame, {}, input_path)
91
+ if suffix == ".json":
92
+ with input_path.open(encoding="utf-8") as stream:
93
+ payload = json.load(stream)
94
+ if not isinstance(payload, list) or any(not isinstance(row, dict) for row in payload):
95
+ raise DataReadError("JSON input must be an array of record objects")
96
+ if not payload:
97
+ raise DataReadError("JSON records input is empty")
98
+ return Dataset(pd.DataFrame.from_records(payload), {}, input_path)
99
+ return _read_hdf5(input_path)
100
+ except DataReadError:
101
+ raise
102
+ except UnicodeError as exc:
103
+ raise DataReadError(f"Input is not valid UTF-8: {input_path}") from exc
104
+ except json.JSONDecodeError as exc:
105
+ raise DataReadError(f"Invalid JSON in {input_path}: {exc.msg}") from exc
106
+ except (OSError, pd.errors.ParserError, pd.errors.EmptyDataError) as exc:
107
+ raise DataReadError(f"Cannot read {input_path}: {exc}") from exc
108
+
109
+
110
+ def write_hdf5(
111
+ dataset: Dataset,
112
+ output: str | Path,
113
+ schema: ProfileSchema,
114
+ validation: ValidationResult,
115
+ *,
116
+ field_mapping: dict[str, str] | None = None,
117
+ source_description: str | None = None,
118
+ operation_log: list[str] | None = None,
119
+ force: bool = False,
120
+ ) -> Path:
121
+ """Write the documented CPDataKit HDF5 interchange format."""
122
+ target = Path(output)
123
+ if target.exists() and not force:
124
+ raise OutputExistsError(f"Output already exists: {target}; pass force=True to replace it")
125
+ target.parent.mkdir(parents=True, exist_ok=True)
126
+ declared_units = {item.name: item.unit for item in schema.fields if item.name in dataset.data}
127
+ units = {**declared_units, **dataset.metadata.get("units", {})}
128
+ stored_mapping = (
129
+ field_mapping if field_mapping is not None else dataset.metadata.get("field_mapping", {})
130
+ )
131
+ provenance = build_provenance(
132
+ dataset.source, source_description=source_description, operation_log=operation_log
133
+ )
134
+ with h5py.File(target, "w") as handle:
135
+ handle.attrs["format"] = "CPDataKit"
136
+ handle.attrs["format_version"] = "1.0"
137
+ handle.attrs["profile"] = schema.profile
138
+ handle.attrs["schema_version"] = schema.schema_version
139
+ handle.attrs["units_json"] = json.dumps(units, sort_keys=True)
140
+ handle.attrs["field_mapping_json"] = json.dumps(stored_mapping, sort_keys=True)
141
+ handle.attrs["provenance_json"] = json.dumps(provenance, sort_keys=True)
142
+ handle.attrs["validation_summary_json"] = json.dumps(
143
+ {
144
+ "valid": validation.valid,
145
+ "error_count": len(validation.errors),
146
+ "warning_count": len(validation.warnings),
147
+ },
148
+ sort_keys=True,
149
+ )
150
+ group = handle.create_group("data")
151
+ for name in dataset.data.columns:
152
+ values = dataset.data[name].to_numpy()
153
+ if values.dtype.kind in {"O", "U"}:
154
+ if len(values) and all(
155
+ isinstance(item, (list, tuple, np.ndarray)) for item in values
156
+ ):
157
+ try:
158
+ values = np.stack(values)
159
+ except ValueError as exc:
160
+ raise DataReadError(
161
+ f"Cannot write inconsistent array shapes in field {name!r}"
162
+ ) from exc
163
+ else:
164
+ values = values.astype(h5py.string_dtype(encoding="utf-8"))
165
+ group.create_dataset(name, data=values)
166
+ return target
cpdatakit/model.py ADDED
@@ -0,0 +1,59 @@
1
+ """Stable data and validation result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ import pandas as pd
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class Dataset:
14
+ """A tabular dataset plus explicit metadata and optional source path."""
15
+
16
+ data: pd.DataFrame
17
+ metadata: dict[str, Any] = field(default_factory=dict)
18
+ source: Path | None = None
19
+
20
+ def copy(self) -> Dataset:
21
+ """Return a deep-enough copy for safe normalization."""
22
+ return Dataset(self.data.copy(deep=True), dict(self.metadata), self.source)
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ValidationIssue:
27
+ """One actionable validation finding."""
28
+
29
+ code: str
30
+ field: str | None
31
+ message: str
32
+ affected_records: int
33
+ suggestion: str | None = None
34
+ severity: Literal["error", "warning"] = "error"
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class ValidationResult:
39
+ """Structured validation report; validity means schema conformance only."""
40
+
41
+ errors: list[ValidationIssue] = field(default_factory=list)
42
+ warnings: list[ValidationIssue] = field(default_factory=list)
43
+
44
+ @property
45
+ def valid(self) -> bool:
46
+ """Whether no schema-conformance errors were found."""
47
+ return not self.errors
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ """Return a JSON-serializable representation."""
51
+ return {
52
+ "valid": self.valid,
53
+ "errors": [asdict(item) for item in self.errors],
54
+ "warnings": [asdict(item) for item in self.warnings],
55
+ "scope_note": (
56
+ "Validation checks declared format constraints; it does not certify "
57
+ "physical or scientific correctness."
58
+ ),
59
+ }
@@ -0,0 +1,101 @@
1
+ """Explicit field mapping and unit conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import pandas as pd
8
+ from pint import DimensionalityError, UndefinedUnitError, UnitRegistry
9
+
10
+ from .exceptions import NormalizationError
11
+ from .model import Dataset
12
+ from .schema import ProfileSchema, load_schema
13
+
14
+ _UREG = UnitRegistry()
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class FieldMapping:
19
+ """An explicit, traceable field mapping and optional unit conversion."""
20
+
21
+ source: str
22
+ target: str
23
+ input_unit: str | None = None
24
+ output_unit: str | None = None
25
+ source_note: str = "user supplied"
26
+
27
+
28
+ def normalize_dataset(
29
+ dataset: Dataset,
30
+ schema: str | ProfileSchema,
31
+ mappings: list[FieldMapping] | None = None,
32
+ *,
33
+ drop_unmapped: bool = False,
34
+ ) -> Dataset:
35
+ """Normalize fields only from explicit mappings; never infer scientific conventions."""
36
+ contract = load_schema(schema)
37
+ items = list(mappings or [])
38
+ sources = [item.source for item in items]
39
+ targets = [item.target for item in items]
40
+ if len(sources) != len(set(sources)) or len(targets) != len(set(targets)):
41
+ raise NormalizationError("Mappings contain a duplicate source or target")
42
+ unknown = set(targets) - set(contract.field_map())
43
+ if unknown:
44
+ raise NormalizationError(
45
+ f"Mapping targets are not declared by the schema: {sorted(unknown)}"
46
+ )
47
+ missing = set(sources) - set(dataset.data.columns)
48
+ if missing:
49
+ raise NormalizationError(f"Mapping sources do not exist: {sorted(missing)}")
50
+ collisions = {
51
+ item.target for item in items if item.target in dataset.data and item.target != item.source
52
+ }
53
+ if collisions:
54
+ raise NormalizationError(f"Mappings would overwrite existing fields: {sorted(collisions)}")
55
+
56
+ result = dataset.copy()
57
+ units = dict(result.metadata.get("units", {}))
58
+ mapping_log: dict[str, dict[str, str | None]] = {}
59
+ for item in items:
60
+ series = result.data[item.source]
61
+ if bool(item.input_unit) != bool(item.output_unit):
62
+ raise NormalizationError("Both input_unit and output_unit are required for conversion")
63
+ if item.input_unit and item.output_unit:
64
+ try:
65
+ values = series.astype(float).to_numpy()
66
+ except (TypeError, ValueError) as exc:
67
+ raise NormalizationError(f"Field {item.source!r} is not numeric") from exc
68
+ try:
69
+ converted = _UREG.Quantity(values, item.input_unit).to(item.output_unit).magnitude
70
+ except (DimensionalityError, UndefinedUnitError) as exc:
71
+ raise NormalizationError(
72
+ f"Cannot convert {item.source!r} from {item.input_unit!r} "
73
+ f"to {item.output_unit!r}: {exc}"
74
+ ) from exc
75
+ series = pd.Series(converted, index=series.index, name=series.name)
76
+ units[item.target] = item.output_unit
77
+ elif item.source in units:
78
+ units[item.target] = units[item.source]
79
+ result.data[item.target] = series
80
+ if item.target != item.source:
81
+ result.data = result.data.drop(columns=[item.source])
82
+ units.pop(item.source, None)
83
+ mapping_log[item.source] = {
84
+ "target": item.target,
85
+ "input_unit": item.input_unit,
86
+ "output_unit": item.output_unit,
87
+ "source_note": item.source_note,
88
+ }
89
+ if drop_unmapped:
90
+ keep = [item.name for item in contract.fields if item.name in result.data]
91
+ result.data = result.data.loc[:, keep]
92
+ units = {key: value for key, value in units.items() if key in keep}
93
+ result.metadata.update(
94
+ {
95
+ "profile": contract.profile,
96
+ "schema_version": contract.schema_version,
97
+ "units": units,
98
+ "field_mapping": mapping_log,
99
+ }
100
+ )
101
+ return result
cpdatakit/plotting.py ADDED
@@ -0,0 +1,132 @@
1
+ """Headless-safe scientific plotting helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import matplotlib
8
+
9
+ matplotlib.use("Agg")
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+ from matplotlib import rcParams
13
+ from matplotlib.axes import Axes
14
+ from matplotlib.figure import Figure
15
+
16
+ from .exceptions import CPDataKitError, OutputExistsError
17
+ from .model import Dataset
18
+ from .schema import ProfileSchema, load_schema
19
+
20
+ _BLUE = "#3B6FB6"
21
+ _INK = "#263238"
22
+ rcParams["svg.hashsalt"] = "cpdatakit-0.1"
23
+
24
+
25
+ def _unit(schema: ProfileSchema, field: str) -> str:
26
+ spec = schema.field_map().get(field)
27
+ return spec.unit if spec and spec.unit else "unit not declared"
28
+
29
+
30
+ def _finish(fig: Figure, ax: Axes) -> tuple[Figure, Axes]:
31
+ ax.tick_params(colors=_INK)
32
+ for side in ("top", "right"):
33
+ ax.spines[side].set_visible(False)
34
+ ax.grid(axis="y", color="#D9DEE3", linewidth=0.7, alpha=0.7)
35
+ fig.tight_layout()
36
+ return fig, ax
37
+
38
+
39
+ def plot_stress_strain(dataset: Dataset, schema: str | ProfileSchema) -> tuple[Figure, Axes]:
40
+ """Plot an explicitly declared scalar stress-strain curve."""
41
+ contract = load_schema(schema)
42
+ if "strain" not in dataset.data or "stress" not in dataset.data:
43
+ raise CPDataKitError("stress-strain plot requires declared 'strain' and 'stress' fields")
44
+ fig, ax = plt.subplots(figsize=(7.2, 4.5))
45
+ ax.plot(dataset.data["strain"], dataset.data["stress"], color=_BLUE, label="Synthetic curve")
46
+ ax.set(
47
+ title="Stress-strain curve",
48
+ xlabel=f"Strain [{_unit(contract, 'strain')}]",
49
+ ylabel=f"Stress [{_unit(contract, 'stress')}]",
50
+ )
51
+ ax.legend(frameon=False)
52
+ return _finish(fig, ax)
53
+
54
+
55
+ def plot_histogram(
56
+ dataset: Dataset, schema: str | ProfileSchema, field: str
57
+ ) -> tuple[Figure, Axes]:
58
+ """Plot a finite-value histogram for a declared numeric field."""
59
+ contract = load_schema(schema)
60
+ if field not in dataset.data or field not in contract.field_map():
61
+ raise CPDataKitError(f"Histogram field is absent or undeclared: {field}")
62
+ spec = contract.field_map()[field]
63
+ if spec.dtype not in {"float", "integer"} or spec.shape:
64
+ raise CPDataKitError(f"Histogram field must be a declared scalar numeric field: {field}")
65
+ try:
66
+ values = np.asarray(dataset.data[field], dtype=float)
67
+ except (TypeError, ValueError) as exc:
68
+ raise CPDataKitError(f"Histogram field is not numeric: {field}") from exc
69
+ values = values[np.isfinite(values)]
70
+ if not len(values):
71
+ raise CPDataKitError(f"Histogram field has no finite data: {field}")
72
+ fig, ax = plt.subplots(figsize=(7.2, 4.5))
73
+ ax.hist(values, bins="auto", color=_BLUE, edgecolor="white", label=field)
74
+ ax.set(
75
+ title=f"Distribution of {field}",
76
+ xlabel=f"{field} [{_unit(contract, field)}]",
77
+ ylabel="Count [records]",
78
+ )
79
+ ax.legend(frameon=False)
80
+ return _finish(fig, ax)
81
+
82
+
83
+ def plot_counts(dataset: Dataset, schema: str | ProfileSchema, field: str) -> tuple[Figure, Axes]:
84
+ """Plot record counts for grain_id or phase_id."""
85
+ load_schema(schema)
86
+ if field not in {"grain_id", "phase_id"} or field not in dataset.data:
87
+ raise CPDataKitError("Count plot field must be present grain_id or phase_id")
88
+ counts = dataset.data[field].value_counts().sort_index()
89
+ fig, ax = plt.subplots(figsize=(7.2, 4.5))
90
+ ax.bar(counts.index.astype(str), counts.values, color=_BLUE, label="Record count")
91
+ ax.set(title=f"Records by {field}", xlabel=f"{field} [identifier]", ylabel="Count [records]")
92
+ ax.legend(frameon=False)
93
+ return _finish(fig, ax)
94
+
95
+
96
+ def plot_field2d(dataset: Dataset, schema: str | ProfileSchema) -> tuple[Figure, Axes]:
97
+ """Plot an irregular or regular 2D scalar field without interpolation claims."""
98
+ contract = load_schema(schema)
99
+ needed = {"x", "y", "value"}
100
+ if not needed.issubset(dataset.data):
101
+ raise CPDataKitError("field2d plot requires x, y, and value")
102
+ fig, ax = plt.subplots(figsize=(6.2, 5.2))
103
+ points = ax.scatter(
104
+ dataset.data["x"],
105
+ dataset.data["y"],
106
+ c=dataset.data["value"],
107
+ cmap="viridis",
108
+ s=35,
109
+ label="Samples",
110
+ )
111
+ colorbar = fig.colorbar(points, ax=ax)
112
+ colorbar.set_label(f"Value [{_unit(contract, 'value')}]")
113
+ ax.set(
114
+ title="Two-dimensional scalar field",
115
+ xlabel=f"x [{_unit(contract, 'x')}]",
116
+ ylabel=f"y [{_unit(contract, 'y')}]",
117
+ )
118
+ ax.legend(frameon=False)
119
+ return _finish(fig, ax)
120
+
121
+
122
+ def save_figure(fig: Figure, output: str | Path, *, force: bool = False) -> Path:
123
+ """Save a PNG or SVG without overwriting unless requested."""
124
+ target = Path(output)
125
+ if target.suffix.lower() not in {".png", ".svg"}:
126
+ raise CPDataKitError("Plot output extension must be .png or .svg")
127
+ if target.exists() and not force:
128
+ raise OutputExistsError(f"Output already exists: {target}; pass force=True to replace it")
129
+ target.parent.mkdir(parents=True, exist_ok=True)
130
+ metadata = {"Date": None} if target.suffix.lower() == ".svg" else {"Software": "CPDataKit"}
131
+ fig.savefig(target, dpi=180, bbox_inches="tight", metadata=metadata)
132
+ return target