# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""EMTF XML -> format-neutral :class:`pycsamt.emtf.EMTF` reader."""
from __future__ import annotations
from dataclasses import asdict
from os import PathLike
from typing import Any, IO
import xml.etree.ElementTree as ET
import numpy as np
from ...api.property import PyCSAMTObject
from ...metadata import (
ChannelMeta,
CopyrightInfo,
LocationMeta,
OrientationMeta,
Person,
ProcessingMeta,
ProvenanceMeta,
QualityComment,
Reference,
RemoteReferenceMeta,
SiteLayout,
SiteMeta,
Software,
TransferFunctionQuality,
)
from ..datatypes import get_emtf_datatype
from ..document import EMTF
from ..estimates import StatisticalEstimate
from ..transfer import TransferFunction
from .constants import (
EMTF_ROOT,
ESTIMATE_CODES,
FREQUENCY_UNIT_ALIASES,
PERIOD_UNIT_ALIASES,
)
from .parser import (
EMTFXMLParseError,
XMLDataTypeSpec,
child,
children,
element_to_mapping,
load_xml_root,
local_name,
parse_numeric_text,
text,
warn,
)
__all__ = ["EMTFXMLReader", "read_emtf_xml"]
[docs]
class EMTFXMLReader(PyCSAMTObject):
"""Read EMTF XML without routing the document through EDI.
Parameters
----------
strict : bool, default=True
In strict mode malformed scientific content raises
:class:`EMTFXMLParseError`. In permissive mode recoverable problems are
warned about and the reader preserves as much content as possible.
"""
def __init__(self, *, strict: bool = True) -> None:
self.strict = bool(strict)
self.source_label = "<unknown>"
[docs]
def read(
self,
source: str | bytes | PathLike[str] | IO[str] | IO[bytes],
) -> EMTF:
"""Read one EMTF XML source into the scientific EMTF model."""
root, self.source_label = load_xml_root(source)
if local_name(root.tag) != EMTF_ROOT:
self._problem(
f"root element must be <{EMTF_ROOT}>, got "
f"<{local_name(root.tag)}>"
)
tags = self._parse_tags(text(root, "Tags"))
provenance = self._parse_provenance(child(root, "Provenance"))
copyright_info = self._parse_copyright(child(root, "Copyright"))
site_node = child(root, "Site")
site = self._parse_site(site_node)
orientation = self._parse_orientation(site_node, root)
quality = self._parse_quality(site_node)
processing = self._parse_processing(child(root, "ProcessingInfo"))
layout = self._parse_site_layout(root)
field_notes = self._parse_field_notes(root)
metadata = self._parse_auxiliary_metadata(root)
specs = self._parse_data_type_specs(root)
metadata["xml_data_types"] = [asdict(spec) for spec in specs.values()]
metadata["xml_statistical_estimates"] = (
self._parse_estimate_declarations(root)
)
metadata["source_format"] = "emtf_xml"
metadata["source"] = self.source_label
period_nodes = self._period_nodes(root)
periods, period_nodes = self._parse_periods(root, period_nodes)
document = EMTF(
product_id=text(root, "ProductId"),
description=text(root, "Description"),
subtype=text(root, "SubType"),
tags=tags,
periods=periods,
provenance=provenance,
copyright=copyright_info,
site=site,
site_layout=layout,
orientation=orientation,
processing=processing,
quality=quality,
field_notes=field_notes,
metadata=metadata,
)
if period_nodes:
if not specs:
specs = self._infer_data_type_specs(period_nodes)
document.metadata["xml_data_types_inferred"] = True
document.metadata["xml_data_types"] = [
asdict(spec) for spec in specs.values()
]
for spec in specs.values():
tf = self._parse_transfer_function(
spec,
period_nodes=period_nodes,
periods=periods,
layout=layout,
)
if tf is not None:
document.add_transfer_function(tf, replace=True)
return document
# ------------------------------------------------------------------
# Document/header metadata
# ------------------------------------------------------------------
@staticmethod
def _parse_tags(value: str | None) -> tuple[str, ...]:
if not value:
return ()
raw = value.replace(";", ",")
return tuple(
item.strip().lower() for item in raw.split(",") if item.strip()
)
def _parse_provenance(
self, node: ET.Element | None
) -> ProvenanceMeta | None:
if node is None:
return None
return ProvenanceMeta(
create_time=text(node, "CreateTime"),
creating_application=text(node, "CreatingApplication"),
creator=self._parse_person(child(node, "Creator")),
submitter=self._parse_person(child(node, "Submitter")),
)
@staticmethod
def _parse_person(node: ET.Element | None) -> Person | None:
if node is None:
return None
values = {
"name": text(node, "Name"),
"email": text(node, "Email"),
"organization": text(node, "Org"),
"organization_url": text(node, "OrgUrl"),
}
if not any(values.values()):
return None
return Person(**values)
def _parse_copyright(
self, node: ET.Element | None
) -> CopyrightInfo | None:
if node is None:
return None
citation = child(node, "Citation")
title = text(citation, "Title") or ""
authors = text(citation, "Authors") or ""
year_text = text(citation, "Year")
year: int | None = None
extra: dict[str, Any] = {}
if year_text:
try:
year = int(year_text)
except ValueError:
extra["year_text"] = year_text
doi_raw = text(citation, "DOI")
doi = None
if doi_raw:
candidate = doi_raw.strip()
if candidate.lower().startswith("doi:"):
candidate = candidate[4:].strip()
if Reference.DOI_PATTERN.match(candidate):
doi = candidate
else:
extra["doi"] = doi_raw
survey_doi = text(citation, "SurveyDOI")
if survey_doi:
extra["survey_doi"] = survey_doi
for key in (
"SelectedPublications",
"Acknowledgement",
"AdditionalInfo",
):
value = text(node, key)
if value:
extra[key.lower()] = value
reference = Reference(
author=authors,
title=title,
year=year,
doi=doi,
extra=extra,
)
return CopyrightInfo(
release_status=text(node, "ReleaseStatus") or "",
conditions_of_use=text(node, "ConditionsOfUse") or "",
reference=reference,
)
def _parse_site(self, node: ET.Element | None) -> SiteMeta | None:
if node is None:
return None
location = self._parse_location(child(node, "Location"))
year = self._integer(text(node, "YearCollected"), "YearCollected")
extra: dict[str, Any] = {}
run_list = text(node, "RunList")
if run_list:
extra["run_list"] = run_list
comments = [
(item.text or "").strip()
for item in children(node, "Comments")
if (item.text or "").strip()
]
if comments:
extra["comments"] = comments
try:
return SiteMeta(
project=text(node, "Project"),
survey=text(node, "Survey"),
year_collected=year,
country=text(node, "Country"),
site_id=text(node, "Id"),
name=text(node, "Name"),
location=location,
acquired_by=text(node, "AcquiredBy"),
start=text(node, "Start"),
end=text(node, "End"),
extra=extra,
)
except (TypeError, ValueError) as exc:
self._problem(f"invalid <Site> metadata: {exc}")
return None
def _parse_location(
self, node: ET.Element | None
) -> LocationMeta | None:
if node is None:
return None
elevation = child(node, "Elevation")
declination = child(node, "Declination")
try:
return LocationMeta(
latitude=self._float(text(node, "Latitude"), "Latitude"),
longitude=self._float(text(node, "Longitude"), "Longitude"),
elevation=self._float(
text(elevation), "Elevation", required=False
),
datum=node.attrib.get("datum") or "WGS84",
elevation_units=(
elevation.attrib.get("units", "meters")
if elevation is not None
else "meters"
),
declination=self._float(
text(declination), "Declination", required=False
),
declination_epoch=self._float(
declination.attrib.get("epoch")
if declination is not None
else None,
"Declination epoch",
required=False,
),
)
except (TypeError, ValueError) as exc:
self._problem(f"invalid <Location> metadata: {exc}")
return None
def _parse_orientation(
self,
site: ET.Element | None,
root: ET.Element,
) -> OrientationMeta | None:
node = child(site, "Orientation")
rotation_info = None
copyright_node = child(root, "Copyright")
for parent in (copyright_node, root):
candidate = text(parent, "RotationInfo")
if candidate:
rotation_info = candidate
break
if node is None and rotation_info is None:
return None
mode = text(node)
angle = self._float(
node.attrib.get("angle_to_geographic_north")
if node is not None
else None,
"Orientation angle_to_geographic_north",
required=False,
)
try:
return OrientationMeta(
mode=mode,
angle_to_geographic_north=angle,
rotation_info=rotation_info,
)
except (TypeError, ValueError) as exc:
self._problem(f"invalid orientation metadata: {exc}")
return None
def _parse_quality(
self, site: ET.Element | None
) -> TransferFunctionQuality | None:
if site is None:
return None
notes = child(site, "DataQualityNotes")
warnings_node = child(site, "DataQualityWarnings")
if notes is None and warnings_node is None:
return None
comments = self._quality_comments(notes)
warning_comments = self._quality_comments(warnings_node)
rating = self._integer(text(notes, "Rating"), "quality Rating")
warning_flag = self._integer(
text(warnings_node, "Flag"), "quality Flag"
)
try:
return TransferFunctionQuality(
rating=rating,
good_from_period=self._float(
text(notes, "GoodFromPeriod"),
"GoodFromPeriod",
required=False,
),
good_to_period=self._float(
text(notes, "GoodToPeriod"),
"GoodToPeriod",
required=False,
),
comments=comments,
warning_flag=warning_flag,
warnings=warning_comments,
)
except (TypeError, ValueError) as exc:
self._problem(f"invalid data-quality metadata: {exc}")
return None
@staticmethod
def _quality_comments(
node: ET.Element | None,
) -> list[QualityComment]:
if node is None:
return []
result: list[QualityComment] = []
for item in children(node, "Comments"):
value = (item.text or "").strip()
if not value:
continue
result.append(
QualityComment(text=value, author=item.attrib.get("author"))
)
return result
def _parse_processing(
self, node: ET.Element | None
) -> ProcessingMeta | None:
if node is None:
return None
software_node = child(node, "ProcessingSoftware")
software = None
if software_node is not None and any(
text(software_node, name)
for name in ("Name", "LastMod", "Author")
):
software = Software(
name=text(software_node, "Name") or "unknown",
release=text(software_node, "LastMod"),
author=Person(name=text(software_node, "Author")),
)
remote_node = child(node, "RemoteRef")
remote_site = None
remote_info = child(node, "RemoteInfo")
if remote_info is not None:
remote_site_node = child(remote_info, "Site")
remote_site = text(remote_site_node, "Id")
remote = None
if remote_node is not None or remote_site is not None:
remote = RemoteReferenceMeta(
reference_type=(
remote_node.attrib.get("type")
if remote_node is not None
else None
),
site=remote_site,
)
extra: dict[str, Any] = {}
process_date = text(node, "ProcessDate")
if process_date:
extra["process_date"] = process_date
if remote_info is not None:
extra["remote_info"] = element_to_mapping(remote_info)
return ProcessingMeta(
sign_convention=text(node, "SignConvention"),
processed_by=text(node, "ProcessedBy"),
software=software,
remote_reference=remote,
processing_tag=text(node, "ProcessingTag"),
extra=extra,
)
def _parse_site_layout(self, root: ET.Element) -> SiteLayout | None:
layout_node = child(root, "SiteLayout")
legacy = False
if layout_node is None:
if child(root, "InputChannels") is None and child(
root, "OutputChannels"
) is None:
return None
layout_node = root
legacy = True
warn(
f"{self.source_label}: reading legacy EMTF XML without "
"<SiteLayout>; data orientation must be interpreted with "
"channel geometry"
)
input_node = child(layout_node, "InputChannels")
output_node = child(layout_node, "OutputChannels")
input_channels = self._parse_channel_group(input_node)
output_channels = self._parse_channel_group(output_node)
try:
return SiteLayout(
input_channels=input_channels,
output_channels=output_channels,
input_units=(
input_node.attrib.get("units")
if input_node is not None
else None
),
output_units=(
output_node.attrib.get("units")
if output_node is not None
else None
),
input_reference=(
input_node.attrib.get("ref")
if input_node is not None
else None
),
output_reference=(
output_node.attrib.get("ref")
if output_node is not None
else None
),
extra={"legacy_without_sitelayout": True} if legacy else {},
)
except (TypeError, ValueError) as exc:
self._problem(f"invalid <SiteLayout>: {exc}")
return None
def _parse_channel_group(
self, node: ET.Element | None
) -> list[ChannelMeta]:
if node is None:
return []
result: list[ChannelMeta] = []
for item in list(node):
kind = local_name(item.tag)
if kind not in {"Magnetic", "Electric"}:
continue
known = {
"name",
"orientation",
"tilt",
"x",
"y",
"z",
"x2",
"y2",
"z2",
"units",
"ref",
"id",
}
extra = {k: v for k, v in item.attrib.items() if k not in known}
name = item.attrib.get("name")
if not name:
self._problem(f"{kind} channel is missing required name")
continue
try:
result.append(
ChannelMeta(
name=name,
field_type=kind,
orientation=self._float(
item.attrib.get("orientation"),
f"channel {name} orientation",
required=False,
),
tilt=self._float(
item.attrib.get("tilt"),
f"channel {name} tilt",
required=False,
),
x=self._float(
item.attrib.get("x"),
f"channel {name} x",
required=False,
),
y=self._float(
item.attrib.get("y"),
f"channel {name} y",
required=False,
),
z=self._float(
item.attrib.get("z"),
f"channel {name} z",
required=False,
),
x2=self._float(
item.attrib.get("x2"),
f"channel {name} x2",
required=False,
),
y2=self._float(
item.attrib.get("y2"),
f"channel {name} y2",
required=False,
),
z2=self._float(
item.attrib.get("z2"),
f"channel {name} z2",
required=False,
),
units=item.attrib.get("units"),
reference=item.attrib.get("ref"),
sensor_id=item.attrib.get("id"),
extra=extra,
)
)
except (TypeError, ValueError) as exc:
self._problem(f"invalid channel {name!r}: {exc}")
return result
def _parse_field_notes(self, root: ET.Element) -> dict[str, Any]:
result: dict[str, Any] = {}
for index, node in enumerate(children(root, "FieldNotes"), start=1):
key = node.attrib.get("run") or f"run_{index}"
value = element_to_mapping(node)
if key in result:
current = result[key]
if not isinstance(current, list):
current = [current]
current.append(value)
result[key] = current
else:
result[key] = value
return result
@staticmethod
def _parse_auxiliary_metadata(root: ET.Element) -> dict[str, Any]:
result: dict[str, Any] = {}
notes = child(root, "Notes")
if notes is not None:
result["notes"] = element_to_mapping(notes)
for key in ("ExternalUrl", "PrimaryData", "Attachment", "GridOrigin"):
values = [element_to_mapping(item) for item in children(root, key)]
if values:
result[key.lower()] = values if len(values) > 1 else values[0]
period_range = child(root, "PeriodRange")
if period_range is not None:
result["period_range"] = dict(period_range.attrib)
return result
# ------------------------------------------------------------------
# Data-type declarations and periods
# ------------------------------------------------------------------
def _parse_data_type_specs(
self, root: ET.Element
) -> dict[str, XMLDataTypeSpec]:
parent = child(root, "DataTypes")
if parent is None:
return {}
result: dict[str, XMLDataTypeSpec] = {}
for node in children(parent, "DataType"):
raw_name = (node.attrib.get("name") or "").strip().upper()
raw_tag = (text(node, "Tag") or "").strip().lower()
registered = get_emtf_datatype(raw_tag or raw_name)
if not raw_name and registered is not None:
raw_name = registered.name
if not raw_tag and registered is not None:
raw_tag = registered.tag
if not raw_name or not raw_tag:
self._problem("<DataType> requires a name and semantic Tag")
continue
raw_kind = (node.attrib.get("type") or "").strip().lower()
if raw_kind not in {"real", "complex"}:
raw_kind = registered.data_kind if registered else "complex"
self._problem(
f"DataType {raw_name} has invalid/missing type; "
f"using {raw_kind!r}",
recoverable=True,
)
intention_text = (text(node, "Intention") or "").lower()
intention = (
"derived" if "derived" in intention_text else "primary"
)
see_also = tuple(
item.strip().lower()
for item in (text(node, "SeeAlso") or "").split(",")
if item.strip()
)
spec = XMLDataTypeSpec(
name=raw_name,
tag=raw_tag,
data_kind=raw_kind,
input_kind=(node.attrib.get("input") or None),
output_kind=(node.attrib.get("output") or None),
units=node.attrib.get("units"),
intention=intention,
description=text(node, "Description") or "",
derived_from=text(node, "DerivedFrom"),
see_also=see_also,
external_url=text(node, "ExternalUrl"),
)
if raw_name in result:
self._problem(f"duplicate DataType declaration: {raw_name}")
continue
result[raw_name] = spec
return result
@staticmethod
def _parse_estimate_declarations(root: ET.Element) -> list[dict[str, Any]]:
parent = child(root, "StatisticalEstimates")
if parent is None:
return []
out: list[dict[str, Any]] = []
for node in children(parent, "Estimate"):
out.append(
{
"name": node.attrib.get("name"),
"type": node.attrib.get("type"),
"description": text(node, "Description"),
"intention": text(node, "Intention"),
"tag": text(node, "Tag"),
"external_url": text(node, "ExternalUrl"),
}
)
return out
@staticmethod
def _period_nodes(root: ET.Element) -> list[ET.Element]:
data = child(root, "Data")
if data is None:
return []
return children(data, "Period")
def _parse_periods(
self,
root: ET.Element,
period_nodes: list[ET.Element],
) -> tuple[np.ndarray | None, list[ET.Element]]:
"""Return ``(periods, kept_period_nodes)``.
In strict mode an invalid period raises immediately, as before. In
permissive mode an invalid period is dropped -- together with its
``<Period>`` node -- rather than substituted with ``NaN``, since a
``NaN`` period cannot survive :class:`~pycsamt.emtf.document.EMTF`'s
own validation and would otherwise defeat permissive mode's purpose
by crashing anyway with an unrelated, uncaught ``ValueError``.
"""
if not period_nodes:
return None, []
data = child(root, "Data")
declared_count = self._integer(
data.attrib.get("count") if data is not None else None,
"Data count",
)
if declared_count is not None and declared_count != len(period_nodes):
self._problem(
"<Data count> does not match the number of <Period> "
f"elements: {declared_count} != {len(period_nodes)}"
)
values: list[float] = []
kept_nodes: list[ET.Element] = []
for index, node in enumerate(period_nodes):
raw = node.attrib.get("value")
numeric = self._float(raw, f"Period[{index}] value", required=True)
if numeric is None or numeric <= 0.0:
self._problem(f"Period[{index}] must be positive")
continue
units = (node.attrib.get("units") or "secs").strip().lower()
if units in FREQUENCY_UNIT_ALIASES:
numeric = 1.0 / numeric
elif units not in PERIOD_UNIT_ALIASES:
self._problem(
f"Period[{index}] has unsupported units {units!r}; "
"assuming seconds",
recoverable=True,
)
values.append(float(numeric))
kept_nodes.append(node)
if not values:
self._problem("no usable <Period> elements remain")
return None, []
return np.asarray(values, dtype=float), kept_nodes
def _infer_data_type_specs(
self, period_nodes: list[ET.Element]
) -> dict[str, XMLDataTypeSpec]:
names: list[str] = []
for period in period_nodes:
for node in list(period):
name = local_name(node.tag)
if "." in name:
continue
code = name.upper()
if code not in names:
names.append(code)
result: dict[str, XMLDataTypeSpec] = {}
for code in names:
definition = get_emtf_datatype(code)
if definition is None:
self._problem(
f"cannot infer undeclared data type {code!r}; skipping",
recoverable=True,
)
continue
result[code] = XMLDataTypeSpec(
name=code,
tag=definition.tag,
data_kind=definition.data_kind,
input_kind=definition.input_kind,
output_kind=definition.output_kind,
units=definition.units,
intention=definition.intention,
description=definition.description,
derived_from=definition.derived_from,
see_also=definition.see_also,
)
return result
# ------------------------------------------------------------------
# Matrix data and statistical estimates
# ------------------------------------------------------------------
def _parse_transfer_function(
self,
spec: XMLDataTypeSpec,
*,
period_nodes: list[ET.Element],
periods: np.ndarray | None,
layout: SiteLayout | None,
) -> TransferFunction | None:
input_names, output_names = self._channel_names_for_spec(spec, layout)
if not spec.is_scalar and (not input_names or not output_names):
inferred_in, inferred_out = self._infer_component_channels(
spec, period_nodes
)
input_names = input_names or inferred_in
output_names = output_names or inferred_out
if not spec.is_scalar and (not input_names or not output_names):
self._problem(
f"cannot determine input/output channels for {spec.name}"
)
return None
nper = len(period_nodes)
nout = max(1, len(output_names))
nin = max(1, len(input_names))
dtype = complex if spec.data_kind == "complex" else float
fill = np.nan + 1j * np.nan if dtype is complex else np.nan
data = np.full((nper, nout, nin), fill, dtype=dtype)
seen_data = False
for iper, period_node in enumerate(period_nodes):
data_node = self._direct_named_child(period_node, spec.name)
if data_node is None:
continue
seen_data = True
self._fill_matrix_values(
data[iper],
data_node,
input_names=input_names,
output_names=output_names,
complex_=spec.data_kind == "complex",
label=f"{spec.name} period {iper}",
scalar=spec.is_scalar,
)
if not seen_data:
return None
tf = TransferFunction(
name=spec.tag,
data=data,
input_channels=input_names,
output_channels=output_names,
units=spec.units,
periods=periods,
attrs={
"xml_name": spec.name,
"xml_data_kind": spec.data_kind,
"xml_input_kind": spec.input_kind,
"xml_output_kind": spec.output_kind,
"xml_intention": spec.intention,
},
)
for code, kind in ESTIMATE_CODES.items():
estimate = self._parse_estimate(
spec,
code=code,
kind=kind,
period_nodes=period_nodes,
input_names=input_names,
output_names=output_names,
)
if estimate is not None:
tf.add_estimate(estimate)
return tf
@staticmethod
def _direct_named_child(
node: ET.Element,
name: str,
) -> ET.Element | None:
wanted = name.upper()
for item in list(node):
if local_name(item.tag).upper() == wanted:
return item
return None
def _channel_names_for_spec(
self,
spec: XMLDataTypeSpec,
layout: SiteLayout | None,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
if spec.is_scalar:
return (), ()
if layout is None:
return (), ()
def family(
channels: list[ChannelMeta],
kind: str | None,
) -> tuple[str, ...]:
if not kind:
return ()
normalized = kind.strip().upper()
if normalized == "H":
return tuple(ch.name for ch in channels if ch.is_magnetic)
if normalized == "E":
return tuple(ch.name for ch in channels if ch.is_electric)
return tuple(ch.name for ch in channels)
return (
family(layout.input_channels, spec.input_kind),
family(layout.output_channels, spec.output_kind),
)
def _infer_component_channels(
self,
spec: XMLDataTypeSpec,
period_nodes: list[ET.Element],
) -> tuple[tuple[str, ...], tuple[str, ...]]:
inputs: list[str] = []
outputs: list[str] = []
for period in period_nodes:
node = self._direct_named_child(period, spec.name)
if node is None:
continue
for value in children(node, "value"):
raw_input = value.attrib.get("input")
raw_output = value.attrib.get("output")
if raw_input and raw_input not in inputs:
inputs.append(raw_input)
if raw_output and raw_output not in outputs:
outputs.append(raw_output)
return tuple(inputs), tuple(outputs)
def _fill_matrix_values(
self,
target: np.ndarray,
node: ET.Element,
*,
input_names: tuple[str, ...],
output_names: tuple[str, ...],
complex_: bool,
label: str,
scalar: bool,
) -> None:
if scalar:
values = children(node, "value")
if not values:
return
if len(values) > 1:
self._problem(f"{label} contains multiple scalar values")
try:
target[0, 0] = parse_numeric_text(
text(values[-1]), complex_=complex_
)
except EMTFXMLParseError as exc:
self._problem(f"{label}: {exc}")
return
input_map = {name.lower(): i for i, name in enumerate(input_names)}
output_map = {name.lower(): i for i, name in enumerate(output_names)}
assigned: set[tuple[int, int]] = set()
for value in children(node, "value"):
in_name = (value.attrib.get("input") or "").strip()
out_name = (value.attrib.get("output") or "").strip()
if not in_name or not out_name:
self._problem(
f"{label} value requires input and output "
"channel attributes"
)
continue
i = input_map.get(in_name.lower())
o = output_map.get(out_name.lower())
if i is None or o is None:
self._problem(
f"{label} references unknown channel "
f"output={out_name!r}, input={in_name!r}"
)
continue
key = (o, i)
if key in assigned:
self._problem(
f"{label} has duplicate component "
f"output={out_name!r}, input={in_name!r}"
)
try:
target[o, i] = parse_numeric_text(
text(value), complex_=complex_
)
assigned.add(key)
except EMTFXMLParseError as exc:
self._problem(f"{label}: {exc}")
def _parse_estimate(
self,
spec: XMLDataTypeSpec,
*,
code: str,
kind: str,
period_nodes: list[ET.Element],
input_names: tuple[str, ...],
output_names: tuple[str, ...],
) -> StatisticalEstimate | None:
if spec.is_scalar and code in {"INVSIGCOV", "RESIDCOV"}:
return None
if code == "VAR":
rows = output_names
cols = input_names
complex_ = False
elif code == "INVSIGCOV":
rows = input_names
cols = input_names
complex_ = True
else:
rows = output_names
cols = output_names
complex_ = True
if spec.is_scalar:
rows = cols = ()
nrow = max(1, len(rows))
ncol = max(1, len(cols))
dtype = complex if complex_ else float
fill = np.nan + 1j * np.nan if complex_ else np.nan
data = np.full((len(period_nodes), nrow, ncol), fill, dtype=dtype)
seen = False
for iper, period in enumerate(period_nodes):
node = self._direct_named_child(period, f"{spec.name}.{code}")
if node is None:
continue
seen = True
self._fill_matrix_values(
data[iper],
node,
input_names=tuple(cols),
output_names=tuple(rows),
complex_=complex_,
label=f"{spec.name}.{code} period {iper}",
scalar=spec.is_scalar,
)
if not seen:
return None
return StatisticalEstimate(
name=code,
kind=kind,
data=data,
attrs={"xml_parent": spec.name},
)
# ------------------------------------------------------------------
# Conversion/diagnostic helpers
# ------------------------------------------------------------------
def _float(
self,
value: str | None,
label: str,
*,
required: bool = False,
) -> float | None:
if value is None or not str(value).strip():
if required:
self._problem(f"missing required numeric field {label}")
return None
try:
return float(str(value).replace("D", "E").replace("d", "e"))
except ValueError:
self._problem(f"invalid numeric field {label}: {value!r}")
return None
def _integer(self, value: str | None, label: str) -> int | None:
if value is None or not str(value).strip():
return None
try:
return int(str(value).strip())
except ValueError:
self._problem(f"invalid integer field {label}: {value!r}")
return None
def _problem(
self,
message: str,
*,
recoverable: bool = False,
) -> None:
full = f"{self.source_label}: {message}"
if self.strict and not recoverable:
raise EMTFXMLParseError(full)
warn(full)
[docs]
def read_emtf_xml(
source: str | bytes | PathLike[str] | IO[str] | IO[bytes],
*,
strict: bool = True,
) -> EMTF:
"""Convenience wrapper around :class:`EMTFXMLReader`."""
return EMTFXMLReader(strict=strict).read(source)