pcsf 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.
Files changed (88) hide show
  1. pcsf/__init__.py +100 -0
  2. pcsf/_logging.py +73 -0
  3. pcsf/_version.py +19 -0
  4. pcsf/adapters/__init__.py +41 -0
  5. pcsf/adapters/_common.py +91 -0
  6. pcsf/adapters/emc.py +296 -0
  7. pcsf/adapters/mare2dem.py +207 -0
  8. pcsf/adapters/modem.py +344 -0
  9. pcsf/adapters/occam2d.py +246 -0
  10. pcsf/algorithms/__init__.py +65 -0
  11. pcsf/algorithms/comparison.py +145 -0
  12. pcsf/algorithms/locate.py +219 -0
  13. pcsf/algorithms/projection.py +90 -0
  14. pcsf/algorithms/registration.py +211 -0
  15. pcsf/algorithms/regrid.py +146 -0
  16. pcsf/algorithms/sampling.py +345 -0
  17. pcsf/algorithms/uncertainty.py +384 -0
  18. pcsf/api/__init__.py +375 -0
  19. pcsf/api/property.py +413 -0
  20. pcsf/api/registry.py +421 -0
  21. pcsf/api/types.py +45 -0
  22. pcsf/cli/__init__.py +5 -0
  23. pcsf/cli/__main__.py +8 -0
  24. pcsf/cli/main.py +183 -0
  25. pcsf/compat/__init__.py +20 -0
  26. pcsf/compat/h5py.py +71 -0
  27. pcsf/compat/numpy.py +32 -0
  28. pcsf/compat/optional.py +101 -0
  29. pcsf/compat/python.py +47 -0
  30. pcsf/core/__init__.py +89 -0
  31. pcsf/core/conformance.py +311 -0
  32. pcsf/core/encodings.py +216 -0
  33. pcsf/core/hdf5.py +639 -0
  34. pcsf/core/provenance.py +170 -0
  35. pcsf/core/record.py +301 -0
  36. pcsf/core/schema.py +939 -0
  37. pcsf/core/text.py +1320 -0
  38. pcsf/core/versioning.py +113 -0
  39. pcsf/datasets/__init__.py +58 -0
  40. pcsf/datasets/mare2dem_tiny/tiny.1.resistivity +8 -0
  41. pcsf/datasets/mare2dem_tiny/tiny.ele.txt +4 -0
  42. pcsf/datasets/mare2dem_tiny/tiny.logfile +10 -0
  43. pcsf/datasets/mare2dem_tiny/tiny.node.txt +6 -0
  44. pcsf/datasets/mare2dem_tiny/tiny.poly +19 -0
  45. pcsf/datasets/modem_tiny/tiny.dat +12 -0
  46. pcsf/datasets/modem_tiny/tiny_NLCG.log +5 -0
  47. pcsf/datasets/modem_tiny/tiny_NLCG_002.rho +19 -0
  48. pcsf/datasets/occam2d_tiny/ITER01.iter +11 -0
  49. pcsf/datasets/occam2d_tiny/ITER02.iter +11 -0
  50. pcsf/datasets/occam2d_tiny/LogFile.logfile +10 -0
  51. pcsf/datasets/occam2d_tiny/Occam2DMesh +21 -0
  52. pcsf/datasets/occam2d_tiny/Occam2DModel +11 -0
  53. pcsf/datasets/occam2d_tiny/OccamDataFile.dat +13 -0
  54. pcsf/decorators.py +263 -0
  55. pcsf/exceptions.py +181 -0
  56. pcsf/native/__init__.py +21 -0
  57. pcsf/native/_tokens.py +85 -0
  58. pcsf/native/mare2dem.py +411 -0
  59. pcsf/native/modem.py +419 -0
  60. pcsf/native/occam2d.py +643 -0
  61. pcsf/plugins/__init__.py +138 -0
  62. pcsf/py.typed +0 -0
  63. pcsf/schemas/__init__.py +99 -0
  64. pcsf/schemas/inversion-record.schema.json +83 -0
  65. pcsf/schemas/model-provenance.schema.json +28 -0
  66. pcsf/studio/__init__.py +201 -0
  67. pcsf/studio/__main__.py +8 -0
  68. pcsf/studio/convert.py +282 -0
  69. pcsf/studio/convert_page.py +664 -0
  70. pcsf/studio/figures.py +622 -0
  71. pcsf/studio/resources/pcsf-studio.png +0 -0
  72. pcsf/studio/resources/pcsf-studio.svg +44 -0
  73. pcsf/studio/sources.py +265 -0
  74. pcsf/studio/style.py +473 -0
  75. pcsf/studio/summary.py +444 -0
  76. pcsf/studio/window.py +2179 -0
  77. pcsf/utils/__init__.py +19 -0
  78. pcsf/utils/arrays.py +116 -0
  79. pcsf/utils/geo.py +101 -0
  80. pcsf/utils/hashing.py +91 -0
  81. pcsf/utils/io.py +154 -0
  82. pcsf/utils/text.py +42 -0
  83. pcsf-0.1.0.dist-info/METADATA +354 -0
  84. pcsf-0.1.0.dist-info/RECORD +88 -0
  85. pcsf-0.1.0.dist-info/WHEEL +5 -0
  86. pcsf-0.1.0.dist-info/entry_points.txt +5 -0
  87. pcsf-0.1.0.dist-info/licenses/LICENSE +28 -0
  88. pcsf-0.1.0.dist-info/top_level.txt +1 -0
pcsf/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """pcsf -- Portable Common Subsurface Format.
2
+
3
+ An exchange format for electromagnetic inversion models, with a binary
4
+ encoding (PCSF, HDF5) and a lossless text encoding (PCSM, optionally
5
+ gzip-compressed), and the algorithms to sample and compare models of
6
+ different geometry.
7
+
8
+ Quick start::
9
+
10
+ import pcsf
11
+
12
+ model = pcsf.read("model.pcsf")
13
+ print(pcsf.info(model))
14
+ report = pcsf.validate(model)
15
+ pcsf.write(model, "model.pcsm.gz")
16
+ """
17
+
18
+ from . import datasets
19
+ from ._version import FORMAT_VERSION, __version__
20
+ from .api import convert, detect, info, read, validate, write
21
+ from .core import (
22
+ DerivedVolume,
23
+ Grid2DGeometry,
24
+ Grid3DGeometry,
25
+ InversionRecord,
26
+ LineEntry,
27
+ ModelProvenance,
28
+ MultilineGeometry,
29
+ PCSFModel,
30
+ StationTable,
31
+ TopographyPerStation,
32
+ TopographyRaster,
33
+ UnstructuredMeshGeometry,
34
+ attach_inversion_record,
35
+ check_conformance,
36
+ check_inversion_record,
37
+ get_inversion_record,
38
+ pcsf_to_pcsm,
39
+ pcsm_to_pcsf,
40
+ read_pcsf,
41
+ read_pcsm,
42
+ write_pcsf,
43
+ write_pcsm,
44
+ )
45
+ from .exceptions import (
46
+ ConformanceError,
47
+ EncodingError,
48
+ FormatError,
49
+ GeometryError,
50
+ MissingDependencyError,
51
+ PCSFError,
52
+ PCSMSyntaxError,
53
+ VersionError,
54
+ )
55
+
56
+ __all__ = [
57
+ "FORMAT_VERSION",
58
+ # errors
59
+ "ConformanceError",
60
+ # data model
61
+ "DerivedVolume",
62
+ "EncodingError",
63
+ "FormatError",
64
+ "GeometryError",
65
+ "Grid2DGeometry",
66
+ "Grid3DGeometry",
67
+ "InversionRecord",
68
+ "LineEntry",
69
+ "MissingDependencyError",
70
+ "ModelProvenance",
71
+ "MultilineGeometry",
72
+ "PCSFError",
73
+ "PCSFModel",
74
+ "PCSMSyntaxError",
75
+ "StationTable",
76
+ "TopographyPerStation",
77
+ "TopographyRaster",
78
+ "UnstructuredMeshGeometry",
79
+ "VersionError",
80
+ "__version__",
81
+ # encodings and records
82
+ "attach_inversion_record",
83
+ "check_conformance",
84
+ "check_inversion_record",
85
+ # facade
86
+ "convert",
87
+ "datasets",
88
+ "detect",
89
+ "get_inversion_record",
90
+ "info",
91
+ "pcsf_to_pcsm",
92
+ "pcsm_to_pcsf",
93
+ "read",
94
+ "read_pcsf",
95
+ "read_pcsm",
96
+ "validate",
97
+ "write",
98
+ "write_pcsf",
99
+ "write_pcsm",
100
+ ]
pcsf/_logging.py ADDED
@@ -0,0 +1,73 @@
1
+ """Package logger.
2
+
3
+ pcsf never configures the root logger. It logs to
4
+ ``logging.getLogger("pcsf")``, which carries a ``NullHandler``, so
5
+ that applications decide where messages go. For interactive use,
6
+ :func:`enable_console_logging` prints them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ __all__ = ["enable_console_logging", "get_logger"]
14
+
15
+ _ROOT = "pcsf"
16
+ logging.getLogger(_ROOT).addHandler(logging.NullHandler())
17
+
18
+
19
+ def get_logger(name: str | None = None) -> logging.Logger:
20
+ """Return the package logger or one of its children.
21
+
22
+ Parameters
23
+ ----------
24
+ name : str, optional
25
+ Module name, usually ``__name__``. Names outside the package
26
+ are attached under ``pcsf``.
27
+
28
+ Returns
29
+ -------
30
+ logging.Logger
31
+ ``pcsf`` itself when *name* is empty, otherwise a child.
32
+
33
+ Examples
34
+ --------
35
+ >>> from pcsf._logging import get_logger
36
+ >>> get_logger("pcsf.core").name, get_logger("plugin").name
37
+ ('pcsf.core', 'pcsf.plugin')
38
+ """
39
+ if not name:
40
+ return logging.getLogger(_ROOT)
41
+ if name == _ROOT or name.startswith(_ROOT + "."):
42
+ return logging.getLogger(name)
43
+ return logging.getLogger(f"{_ROOT}.{name}")
44
+
45
+
46
+ def enable_console_logging(level: int | str = logging.INFO) -> None:
47
+ """Send pcsf log messages to standard error.
48
+
49
+ Parameters
50
+ ----------
51
+ level : int or str, default logging.INFO
52
+ Level set on the ``pcsf`` logger.
53
+
54
+ Notes
55
+ -----
56
+ A stream handler is added only once, however often the function
57
+ is called; later calls only change the level. This modifies
58
+ global logging state and is meant for scripts and notebooks, not
59
+ for libraries.
60
+ """
61
+ logger = logging.getLogger(_ROOT)
62
+ has_stream = any(
63
+ isinstance(h, logging.StreamHandler)
64
+ and not isinstance(h, logging.NullHandler)
65
+ for h in logger.handlers
66
+ )
67
+ if not has_stream:
68
+ handler = logging.StreamHandler()
69
+ handler.setFormatter(
70
+ logging.Formatter("%(levelname)s %(name)s: %(message)s")
71
+ )
72
+ logger.addHandler(handler)
73
+ logger.setLevel(level)
pcsf/_version.py ADDED
@@ -0,0 +1,19 @@
1
+ """Version numbers.
2
+
3
+ Two numbers are kept apart on purpose:
4
+
5
+ ``__version__``
6
+ The version of this Python package (the software).
7
+ ``FORMAT_VERSION``
8
+ The version of the PCSF/PCSM specification the package writes. It
9
+ follows its own semantic-versioning policy (spec/, section
10
+ "Versioning") and changes only when the file layout changes.
11
+ """
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ #: Specification version written to every file (``pcsf_version`` /
16
+ #: ``PCSM_VERSION``).
17
+ FORMAT_VERSION = "0.1.0"
18
+
19
+ __all__ = ["FORMAT_VERSION", "__version__"]
@@ -0,0 +1,41 @@
1
+ """Conversion of solver output to PCSF models.
2
+
3
+ Each adapter reads native files through :mod:`pcsf.native` and
4
+ registers itself in :data:`pcsf.api.registry.ADAPTERS`, so that
5
+ ``ADAPTERS.get("occam2d")`` returns :func:`occam2d_to_pcsf`.
6
+ Third-party solvers plug in through the ``pcsf.adapters``
7
+ entry-point group (see :mod:`pcsf.plugins`).
8
+
9
+ ========== ======================== =====================
10
+ Name Function Geometry kind
11
+ ========== ======================== =====================
12
+ occam2d :func:`occam2d_to_pcsf` ``grid2d``
13
+ modem :func:`modem_to_pcsf` ``grid3d``
14
+ mare2dem :func:`mare2dem_to_pcsf` ``mesh_unstructured``
15
+ emc :func:`emc_to_pcsf` ``grid3d`` (geographic)
16
+ ========== ======================== =====================
17
+
18
+ :func:`pcsf.adapters.emc.pcsf_to_emc` writes an EMC model back to
19
+ netCDF. MARE2DEM triangulation needs ``pcsf[mare2dem]`` and EMC files
20
+ need ``pcsf[emc]``; both are imported only when used.
21
+
22
+ Examples
23
+ --------
24
+ >>> from pcsf.api.registry import ADAPTERS
25
+ >>> import pcsf.adapters
26
+ >>> ADAPTERS.names()
27
+ ('emc', 'mare2dem', 'modem', 'occam2d')
28
+ """
29
+
30
+ from .emc import emc_to_pcsf, pcsf_to_emc
31
+ from .mare2dem import mare2dem_to_pcsf
32
+ from .modem import modem_to_pcsf
33
+ from .occam2d import occam2d_to_pcsf
34
+
35
+ __all__ = [
36
+ "emc_to_pcsf",
37
+ "mare2dem_to_pcsf",
38
+ "modem_to_pcsf",
39
+ "occam2d_to_pcsf",
40
+ "pcsf_to_emc",
41
+ ]
@@ -0,0 +1,91 @@
1
+ """Helpers shared by the adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ..core.schema import StationTable, TopographyPerStation
11
+
12
+ __all__ = ["station_table", "survey_dict"]
13
+
14
+
15
+ def survey_dict(survey: Any) -> dict[str, Any]:
16
+ """Plain dictionary of survey metadata.
17
+
18
+ Parameters
19
+ ----------
20
+ survey : mapping or object or None
21
+ Mapping, object with ``to_dict()``, or ``None``.
22
+
23
+ Returns
24
+ -------
25
+ dict
26
+ Empty for ``None``.
27
+ """
28
+ if survey is None:
29
+ return {}
30
+ to_dict = getattr(survey, "to_dict", None)
31
+ if callable(to_dict):
32
+ return dict(to_dict())
33
+ return dict(survey)
34
+
35
+
36
+ def station_table(
37
+ names: Sequence[str],
38
+ x: np.ndarray,
39
+ y: np.ndarray,
40
+ z: np.ndarray,
41
+ *,
42
+ station_lonlat: Mapping[str, tuple[float, float]] | None = None,
43
+ station_elevations: Mapping[str, float] | None = None,
44
+ ) -> tuple[StationTable, TopographyPerStation | None]:
45
+ """Station table and optional topography from per-name values.
46
+
47
+ Parameters
48
+ ----------
49
+ names : sequence of str
50
+ Station names in file order.
51
+ x, y, z : array_like of shape (n,)
52
+ Model-frame coordinates, metres.
53
+ station_lonlat : mapping of str to (lon, lat), optional
54
+ WGS84 positions; stations missing from it get NaN.
55
+ station_elevations : mapping of str to float, optional
56
+ Elevations in metres; they replace *z* for the stations they
57
+ name and define the topography.
58
+
59
+ Returns
60
+ -------
61
+ stations : StationTable
62
+ topography : TopographyPerStation or None
63
+ Set only when *station_elevations* is given.
64
+ """
65
+ names = list(names)
66
+ z = np.asarray(z, dtype=float).copy()
67
+ topo = None
68
+ if station_elevations:
69
+ for i, name in enumerate(names):
70
+ if name in station_elevations:
71
+ z[i] = float(station_elevations[name])
72
+ topo = TopographyPerStation(
73
+ station_id=list(names), elevation=z
74
+ )
75
+ lon = lat = None
76
+ if station_lonlat:
77
+ pairs = np.array(
78
+ [station_lonlat.get(n, (np.nan, np.nan)) for n in names],
79
+ dtype=float,
80
+ ).reshape(len(names), 2)
81
+ if not np.all(np.isnan(pairs)):
82
+ lon, lat = pairs[:, 0], pairs[:, 1]
83
+ table = StationTable(
84
+ name=names,
85
+ x=np.asarray(x, dtype=float),
86
+ y=np.asarray(y, dtype=float),
87
+ z=z,
88
+ lon=lon,
89
+ lat=lat,
90
+ )
91
+ return table, topo
pcsf/adapters/emc.py ADDED
@@ -0,0 +1,296 @@
1
+ """EarthScope Earth Model Collaboration (EMC) netCDF files.
2
+
3
+ EMC [1]_ archives 3-D models as netCDF files on a geographic grid
4
+ (longitude, latitude, depth in km). :func:`emc_to_pcsf` converts
5
+ such a file to a ``grid3d`` model and :func:`pcsf_to_emc` writes it
6
+ back; the pair is lossless: values (fill values included), coordinate
7
+ and value types, every global and variable attribute with its type,
8
+ and the netCDF format are restored. Needs ``pcsf[emc]`` (netCDF4).
9
+
10
+ Notes
11
+ -----
12
+ Electrical models in EMC usually store
13
+ :math:`\\log_{10}\\sigma` with :math:`\\sigma` in S/m, which pcsf
14
+ records with encoding ``log10_conductivity``, so that
15
+ :math:`\\rho = 10^{-v}`. Depth is converted from km to m, and the
16
+ grid's horizontal coordinates stay in degrees with
17
+ ``crs = "EPSG:4326"``.
18
+
19
+ References
20
+ ----------
21
+ .. [1] Trabant, C., A. R. Hutko, M. Bahavar, R. Karstens, T. Ahern,
22
+ and R. Aster, 2012, Data products at the IRIS DMC: Stepping
23
+ stones for research and other applications: Seismological
24
+ Research Letters, 83, 846-854.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import numpy as np
34
+
35
+ from ..api.registry import register_adapter
36
+ from ..compat.optional import import_optional
37
+ from ..core.schema import Grid3DGeometry, PCSFModel
38
+ from ..exceptions import AdapterError
39
+
40
+ __all__ = ["emc_to_pcsf", "pcsf_to_emc"]
41
+
42
+ _COORDS = ("depth", "latitude", "longitude")
43
+ _ENCODINGS = {
44
+ "log10_conductivity": "log10_conductivity",
45
+ "log10_resistivity": "log10",
46
+ "conductivity": "conductivity",
47
+ "resistivity": "linear",
48
+ }
49
+
50
+
51
+ def _pack(value: Any) -> dict[str, Any]:
52
+ """Attribute value with its type, as JSON-ready data."""
53
+ if isinstance(value, str):
54
+ return {"t": "str", "v": value}
55
+ arr = np.asarray(value)
56
+ return {"t": arr.dtype.str, "v": arr.tolist()}
57
+
58
+
59
+ def _unpack(packed: dict[str, Any]) -> Any:
60
+ if packed["t"] == "str":
61
+ return packed["v"]
62
+ return np.array(packed["v"], dtype=np.dtype(packed["t"]))
63
+
64
+
65
+ def _encoding(name: str, attrs: dict[str, Any]) -> str:
66
+ """Resistivity encoding of an EMC variable from its name."""
67
+ text = " ".join(
68
+ [
69
+ name,
70
+ str(attrs.get("long_name", "")),
71
+ str(attrs.get("units", "")),
72
+ ]
73
+ ).lower()
74
+ for key in ("log10_conductivity", "log10_resistivity"):
75
+ if key in text.replace(" ", "_") or key in name.lower():
76
+ return _ENCODINGS[key]
77
+ if "log" in text and "conduct" in text:
78
+ return "log10_conductivity"
79
+ if "log" in text:
80
+ return "log10"
81
+ if "conduct" in text:
82
+ return "conductivity"
83
+ return "linear"
84
+
85
+
86
+ @register_adapter("emc")
87
+ def emc_to_pcsf(
88
+ path: str | os.PathLike[str],
89
+ *,
90
+ variable: str | None = None,
91
+ encoding: str | None = None,
92
+ created_by: str = "",
93
+ description: str = "",
94
+ ) -> PCSFModel:
95
+ """Convert an EMC netCDF model to a ``grid3d`` model.
96
+
97
+ Parameters
98
+ ----------
99
+ path : str or os.PathLike
100
+ netCDF file.
101
+ variable : str, optional
102
+ 3-D variable to convert; the first 3-D variable by default.
103
+ encoding : str, optional
104
+ Encoding of the values; guessed from the variable's name,
105
+ ``long_name`` and ``units`` by default (usually
106
+ ``log10_conductivity``).
107
+ created_by, description : str, optional
108
+ Stored in the model; the description defaults to the file's
109
+ ``id`` and ``title``.
110
+
111
+ Returns
112
+ -------
113
+ PCSFModel
114
+ ``grid3d`` model with axes ``(depth, latitude, longitude)``,
115
+ depth in metres, linear resistivity, the archived values as
116
+ source values (fill values as NaN), ``crs = "EPSG:4326"``,
117
+ and ``metadata["emc"]`` holding everything
118
+ :func:`pcsf_to_emc` needs.
119
+
120
+ Raises
121
+ ------
122
+ MissingDependencyError
123
+ If netCDF4 is not installed.
124
+ AdapterError
125
+ If the file has no 3-D variable or lacks a coordinate.
126
+
127
+ See Also
128
+ --------
129
+ pcsf_to_emc : Inverse conversion.
130
+
131
+ Examples
132
+ --------
133
+ >>> from pcsf.adapters.emc import emc_to_pcsf
134
+ >>> model = emc_to_pcsf("WUS.MT.nc") # doctest: +SKIP
135
+ >>> model.resistivity_native_encoding # doctest: +SKIP
136
+ 'log10_conductivity'
137
+ """
138
+ nc = import_optional("netCDF4", feature="EMC netCDF files")
139
+ with nc.Dataset(path) as d:
140
+ d.set_auto_mask(False)
141
+ names = [v for v in d.variables if d[v].ndim == 3]
142
+ var = variable or (names[0] if names else None)
143
+ if var is None or var not in d.variables:
144
+ raise AdapterError(f"{path}: no 3-D variable to convert")
145
+ dims = list(d[var].dimensions)
146
+ if sorted(dims) != sorted(_COORDS):
147
+ raise AdapterError(
148
+ f"{path}: {var} has dimensions {dims}, expected "
149
+ f"{list(_COORDS)}"
150
+ )
151
+ v = d[var]
152
+ raw = v[:]
153
+ attrs = {a: v.getncattr(a) for a in v.ncattrs()}
154
+ fill = attrs.get("_FillValue")
155
+ native = raw.astype(np.float64)
156
+ if fill is not None:
157
+ native[raw == fill] = np.nan
158
+ order = [dims.index(c) for c in _COORDS]
159
+ native = np.transpose(native, order)
160
+ coords = {c: np.asarray(d[c][:]) for c in _COORDS}
161
+ meta = {
162
+ "file_format": d.data_model,
163
+ "variable": var,
164
+ "dimensions": dims,
165
+ "coordinate_dtypes": {
166
+ c: coords[c].dtype.str for c in _COORDS
167
+ },
168
+ "value_dtype": v.dtype.str,
169
+ "depth_units": getattr(d["depth"], "units", "km"),
170
+ "global_attributes": {
171
+ a: _pack(d.getncattr(a)) for a in d.ncattrs()
172
+ },
173
+ "variable_attributes": {
174
+ n: {
175
+ a: _pack(d[n].getncattr(a)) for a in d[n].ncattrs()
176
+ }
177
+ for n in d.variables
178
+ },
179
+ }
180
+ gattrs = {a: d.getncattr(a) for a in d.ncattrs()}
181
+ enc = encoding or _encoding(var, attrs)
182
+ from ..core.encodings import to_resistivity
183
+
184
+ scale = 1000.0 if str(meta["depth_units"]).lower() == "km" else 1.0
185
+ geometry = Grid3DGeometry(
186
+ x=coords["longitude"].astype(np.float64),
187
+ y=coords["latitude"].astype(np.float64),
188
+ z=coords["depth"].astype(np.float64) * scale,
189
+ )
190
+ if not description:
191
+ description = (
192
+ f"EMC model {gattrs.get('id', Path(path).stem)}: "
193
+ f"{gattrs.get('title', '')}"
194
+ )
195
+ return PCSFModel(
196
+ geometry=geometry,
197
+ resistivity=to_resistivity(native, enc),
198
+ resistivity_native=native,
199
+ resistivity_native_encoding=enc,
200
+ source_backend="emc",
201
+ created_by=created_by,
202
+ crs="EPSG:4326",
203
+ description=description,
204
+ metadata={"emc": meta},
205
+ )
206
+
207
+
208
+ def pcsf_to_emc(
209
+ model: PCSFModel, path: str | os.PathLike[str]
210
+ ) -> Path:
211
+ """Write a model converted by :func:`emc_to_pcsf` back to EMC.
212
+
213
+ Parameters
214
+ ----------
215
+ model : PCSFModel
216
+ Model carrying ``metadata["emc"]``, possibly after a round
217
+ trip through PCSF or PCSM files.
218
+ path : str or os.PathLike
219
+ Destination netCDF file.
220
+
221
+ Returns
222
+ -------
223
+ pathlib.Path
224
+ *path*.
225
+
226
+ Raises
227
+ ------
228
+ MissingDependencyError
229
+ If netCDF4 is not installed.
230
+ AdapterError
231
+ If the model has no EMC metadata or source values.
232
+
233
+ See Also
234
+ --------
235
+ emc_to_pcsf : Forward conversion.
236
+
237
+ Notes
238
+ -----
239
+ Values are taken from the source values, not recomputed from the
240
+ canonical resistivity, so that the archived numbers are restored
241
+ bit for bit; NaN becomes the fill value again.
242
+
243
+ Examples
244
+ --------
245
+ >>> from pcsf.adapters.emc import emc_to_pcsf, pcsf_to_emc
246
+ >>> model = emc_to_pcsf("WUS.MT.nc") # doctest: +SKIP
247
+ >>> pcsf_to_emc(model, "copy.nc") # doctest: +SKIP
248
+ """
249
+ nc = import_optional("netCDF4", feature="EMC netCDF files")
250
+ meta = (model.metadata or {}).get("emc")
251
+ if meta is None or model.resistivity_native is None:
252
+ raise AdapterError(
253
+ "model has no EMC metadata or source values; it was not "
254
+ "made by emc_to_pcsf"
255
+ )
256
+ path = Path(path)
257
+ var = meta["variable"]
258
+ vattrs = meta["variable_attributes"]
259
+ dims = list(meta["dimensions"])
260
+ geo = model.geometry
261
+ scale = 1000.0 if str(meta["depth_units"]).lower() == "km" else 1.0
262
+ coords = {
263
+ "longitude": geo.x,
264
+ "latitude": geo.y,
265
+ "depth": geo.z / scale,
266
+ }
267
+ data = np.asarray(model.resistivity_native, dtype=np.float64)
268
+ data = np.transpose(data, [_COORDS.index(c) for c in dims])
269
+ with nc.Dataset(path, "w", format=meta["file_format"]) as d:
270
+ for c in dims:
271
+ d.createDimension(c, coords[c].size)
272
+ for c in ("longitude", "latitude", "depth"):
273
+ dt = np.dtype(meta["coordinate_dtypes"][c])
274
+ v = d.createVariable(c, dt, (c,))
275
+ for a, p in vattrs.get(c, {}).items():
276
+ v.setncattr(a, _unpack(p))
277
+ v[:] = coords[c].astype(dt)
278
+ fill = None
279
+ if "_FillValue" in vattrs[var]:
280
+ fill = _unpack(vattrs[var]["_FillValue"])
281
+ v = d.createVariable(
282
+ var,
283
+ np.dtype(meta["value_dtype"]),
284
+ tuple(dims),
285
+ fill_value=fill,
286
+ )
287
+ v.set_auto_mask(False)
288
+ for a, p in vattrs[var].items():
289
+ if a != "_FillValue":
290
+ v.setncattr(a, _unpack(p))
291
+ if fill is not None:
292
+ data = np.where(np.isnan(data), float(fill), data)
293
+ v[:] = data.astype(np.dtype(meta["value_dtype"]))
294
+ for a, p in meta["global_attributes"].items():
295
+ d.setncattr(a, _unpack(p))
296
+ return path