atmos-validation 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/CHANGELOG.md +18 -0
  2. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/PKG-INFO +1 -1
  3. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/convert_ascii/attrs_to_meta.py +11 -3
  4. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/convert_ascii/measurement_metadata_to_schema.json +1 -0
  5. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/convert_ascii/parsers.py +6 -10
  6. atmos_validation-2.2.0/atmos_validation/convert_ascii/tests/test_attrs_to_meta.py +101 -0
  7. atmos_validation-2.2.0/atmos_validation/convert_ascii/tests/test_parsers.py +15 -0
  8. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/__init__.py +1 -0
  9. atmos_validation-2.2.0/atmos_validation/schemas/contractor.py +73 -0
  10. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/metadata.py +3 -2
  11. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/header_metadata.py +1 -0
  12. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/header_names.py +1 -0
  13. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/header_parser.py +9 -0
  14. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/header_position.py +36 -28
  15. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/main.py +20 -1
  16. atmos_validation-2.2.0/atmos_validation/validate_ascii/tests/test_contractor_validation.py +137 -0
  17. atmos_validation-2.2.0/atmos_validation/validate_ascii/tests/test_header_position.py +42 -0
  18. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/utils.py +11 -1
  19. atmos_validation-2.2.0/atmos_validation/validate_netcdf/tests/test_averaging_period_validation.py +46 -0
  20. atmos_validation-2.2.0/atmos_validation/validate_netcdf/tests/test_contractor_validation.py +262 -0
  21. atmos_validation-2.2.0/atmos_validation/validate_netcdf/tests/test_instrument_counts_validation.py +27 -0
  22. atmos_validation-2.2.0/atmos_validation/validate_netcdf/tests/test_location_validation.py +38 -0
  23. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_metadata_schema_validation.py +1 -1
  24. atmos_validation-2.2.0/atmos_validation/validate_netcdf/tests/test_total_water_depth_validation.py +43 -0
  25. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validation_settings.py +1 -0
  26. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/file_attributes.py +161 -1
  27. atmos_validation-2.2.0/atmos_validation/validate_netcdf/validators/tests/__init__.py +0 -0
  28. atmos_validation-2.2.0/atmos_validation/validate_netcdf/validators/variables/__init__.py +0 -0
  29. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/docs/ascii_format.md +3 -2
  30. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/docs/conventions.md +14 -11
  31. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/examples/example_ascii_measurement.dat +2 -1
  32. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/pyproject.toml +1 -1
  33. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  34. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  35. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/workflows/ci-conventional-commit-linter.yml +0 -0
  36. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/workflows/ci-cov-comment.yml +0 -0
  37. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/workflows/ci-doctest.yml +0 -0
  38. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/workflows/ci-lint-pr.yml +0 -0
  39. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/workflows/ci-test-pr.yml +0 -0
  40. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/workflows/pypi-publisher.yml +0 -0
  41. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.github/workflows/release-please.yml +0 -0
  42. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.gitignore +0 -0
  43. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/.vscode/settings.json +0 -0
  44. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/LICENSE +0 -0
  45. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/README.md +0 -0
  46. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/SECURITY.md +0 -0
  47. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/__init__.py +0 -0
  48. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/__main__.py +0 -0
  49. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/convert_ascii/__init__.py +0 -0
  50. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/convert_ascii/df_to_nc.py +0 -0
  51. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/convert_ascii/main.py +0 -0
  52. {atmos_validation-2.1.2/atmos_validation/schemas → atmos_validation-2.2.0/atmos_validation/convert_ascii}/tests/__init__.py +0 -0
  53. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/convert_ascii/utils.py +0 -0
  54. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/classification_level.py +0 -0
  55. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/data_usability_level.py +0 -0
  56. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/data_usability_levels.py +0 -0
  57. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/dim_constants.py +0 -0
  58. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/installation_type.py +0 -0
  59. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/installation_types.py +0 -0
  60. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/instrument_type.py +0 -0
  61. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/instrument_types.py +0 -0
  62. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/parameter_config.py +0 -0
  63. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/parameter_configs.py +0 -0
  64. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/qc_tests.py +0 -0
  65. {atmos_validation-2.1.2/atmos_validation/validate_netcdf → atmos_validation-2.2.0/atmos_validation/schemas}/tests/__init__.py +0 -0
  66. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/tests/test_classification_level.py +0 -0
  67. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/tests/test_parameter_configs.py +0 -0
  68. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/schemas/tests/test_unique_configs.py +0 -0
  69. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/__init__.py +0 -0
  70. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/date_helper.py +0 -0
  71. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/header_date.py +0 -0
  72. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/header_parameter_info.py +0 -0
  73. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/header_parameter_info_list.py +0 -0
  74. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/result_file.py +0 -0
  75. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/row_parser.py +0 -0
  76. {atmos_validation-2.1.2/atmos_validation/validate_netcdf/validators → atmos_validation-2.2.0/atmos_validation/validate_ascii/tests}/__init__.py +0 -0
  77. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_ascii/validate_result.py +0 -0
  78. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/__init__.py +0 -0
  79. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/external_reference_guard.py +0 -0
  80. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/main.py +0 -0
  81. {atmos_validation-2.1.2/atmos_validation/validate_netcdf/validators/dims → atmos_validation-2.2.0/atmos_validation/validate_netcdf/tests}/__init__.py +0 -0
  82. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_blacklisted_globals.py +0 -0
  83. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_coordinate_values_validator.py +0 -0
  84. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_data_usability_validation.py +0 -0
  85. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_dimvars_validator.py +0 -0
  86. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_external_reference_guard.py +0 -0
  87. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_file_attributes.py +0 -0
  88. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_final_reports_validation.py +0 -0
  89. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_height_depth_validation.py +0 -0
  90. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_height_longname_validator.py +0 -0
  91. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_installation_types_validation.py +0 -0
  92. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_lat_lon_validator.py +0 -0
  93. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_main.py +0 -0
  94. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_sig_digs.py +0 -0
  95. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_spatial_validators.py +0 -0
  96. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_time_validator.py +0 -0
  97. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_util.py +0 -0
  98. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_varattrs_validation.py +0 -0
  99. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/tests/test_varinterval_validator.py +0 -0
  100. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/utils.py +0 -0
  101. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validation_logger.py +0 -0
  102. {atmos_validation-2.1.2/atmos_validation/validate_netcdf/validators/tests → atmos_validation-2.2.0/atmos_validation/validate_netcdf/validators}/__init__.py +0 -0
  103. {atmos_validation-2.1.2/atmos_validation/validate_netcdf/validators/variables → atmos_validation-2.2.0/atmos_validation/validate_netcdf/validators/dims}/__init__.py +0 -0
  104. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/dims/dims_validator.py +0 -0
  105. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/dims/dimvars_validator.py +0 -0
  106. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/dims/spatial_validators.py +0 -0
  107. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/dims/time_validator.py +0 -0
  108. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/root_validator.py +0 -0
  109. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/variables/sig_dig_validator.py +0 -0
  110. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/variables/varattrs_validator.py +0 -0
  111. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/variables/vardims_validator.py +0 -0
  112. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/variables/variables_validator.py +0 -0
  113. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/atmos_validation/validate_netcdf/validators/variables/varinterval_validator.py +0 -0
  114. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/docs/run.md +0 -0
  115. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/examples/example_netcdf_measurement.nc +0 -0
  116. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/examples/hindcast_example/example_hindcast_20160101_20160131_T744.nc +0 -0
  117. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/examples/hindcast_example/example_hindcast_20160201_20160229_T696.nc +0 -0
  118. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/examples/hindcast_example/example_hindcast_20160301_20160331_T744.nc +0 -0
  119. {atmos_validation-2.1.2 → atmos_validation-2.2.0}/uv.lock +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.0](https://github.com/equinor/atmos-validation/compare/v2.1.2...v2.2.0) (2026-11-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * Add contractor validation and ascii -> netcdf conversion ([#128](https://github.com/equinor/atmos-validation/issues/128)) ([43769dd](https://github.com/equinor/atmos-validation/commit/43769dd40c3e5c6aa07cfe28f92341fd63c48089))
9
+ * **validate-ascii:** standardize location notation and datum ([#126](https://github.com/equinor/atmos-validation/issues/126)) ([fb2e5e5](https://github.com/equinor/atmos-validation/commit/fb2e5e551fc230ed453e87c58cd984313dc376f2))
10
+ * **validate-netcdf:** validate averaging_period units ([#120](https://github.com/equinor/atmos-validation/issues/120)) ([11d2431](https://github.com/equinor/atmos-validation/commit/11d2431e6c8105a82975def1ed4c80aa6b7dfc9c))
11
+ * **validate-netcdf:** validate instrument_types/instrument_specifications count match ([#119](https://github.com/equinor/atmos-validation/issues/119)) ([47a95b1](https://github.com/equinor/atmos-validation/commit/47a95b1a8e56836bcd2d770db66def5e6864f7e7))
12
+ * **validate-netcdf:** validate location notation and datum ([#125](https://github.com/equinor/atmos-validation/issues/125)) ([6ab4c15](https://github.com/equinor/atmos-validation/commit/6ab4c15a72463fc134dd3714ab1de53880e1bf5f))
13
+ * **validate-netcdf:** validate total_water_depth unit ([#121](https://github.com/equinor/atmos-validation/issues/121)) ([a8a3a98](https://github.com/equinor/atmos-validation/commit/a8a3a984a6f7afc04f1a4aa1b2226cc51316b92d))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * Update contractor and division handling ([#129](https://github.com/equinor/atmos-validation/issues/129)) ([a8ce84f](https://github.com/equinor/atmos-validation/commit/a8ce84f3f408b0e6dd39da52fd05c44ad7de343f))
19
+ * **validate-netcdf:** allow "NA" for averaging_period ([#127](https://github.com/equinor/atmos-validation/issues/127)) ([bc64afb](https://github.com/equinor/atmos-validation/commit/bc64afb97f837a19e3afacba9c9a2b68fa546369))
20
+
3
21
  ## [2.1.2](https://github.com/equinor/atmos-validation/compare/v2.1.1...v2.1.2) (2026-11-09)
4
22
 
5
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: atmos_validation
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: A python library containing automated data validation tools for the Atmos Data Service
5
5
  Project-URL: Repository, https://github.com/equinor/atmos-validation
6
6
  Project-URL: Documentation, https://github.com/equinor/atmos-validation/tree/main/docs
@@ -21,9 +21,7 @@ def parse_measurement_attrs(file_name: str, attrs_map_path: str):
21
21
  if line.startswith("%"):
22
22
  attrs.update(
23
23
  {
24
- metadata: (
25
- *map(lambda s: s.strip(), filter(None, line.split(":"))),
26
- )[-1]
24
+ metadata: line.partition(":")[2].strip()
27
25
  for metadata, attr in attrs_map.items()
28
26
  if line.replace("%", "")
29
27
  .strip()
@@ -39,6 +37,16 @@ def parse_measurement_attrs(file_name: str, attrs_map_path: str):
39
37
  attrs["source_file"] = (
40
38
  file_name.replace("ø", "oe").replace("æ", "ae").replace("å", "aa")
41
39
  ) # NetCDF attrs can not have Norwegian letters
40
+ contractor = attrs.pop("contractor", "NA")
41
+ attrs["contractor"] = (
42
+ [c.strip() for c in contractor.split(",")] if contractor.strip() else []
43
+ )
44
+ contractor_division = attrs.pop("contractor_division", "")
45
+ attrs["contractor_division"] = (
46
+ []
47
+ if not contractor_division.strip()
48
+ else [c.strip() for c in contractor_division.split(",")]
49
+ )
42
50
  if "asset" not in attrs:
43
51
  attrs["asset"] = "NA"
44
52
  if "country" not in attrs:
@@ -2,6 +2,7 @@
2
2
  "asset": "Asset",
3
3
  "comments": "Comments",
4
4
  "contractor": "Contractor/Data Responsible",
5
+ "contractor_division": "Contractor Division",
5
6
  "country": "Country",
6
7
  "data_history": "Data history",
7
8
  "final_reports": "Final Reports",
@@ -13,22 +13,18 @@ MISSING_HEIGHT = -99999
13
13
  def parse_location_text(line_content: str) -> Tuple[float, float]:
14
14
  """Parses Location text into a float tuple (lat, lon)
15
15
 
16
+ Expects the new "<lat>N <lon>E" decimal notation (sign carries the
17
+ hemisphere), e.g. "60.3589N 70.8764E" or "-60.3589N -70.8764E".
18
+
16
19
  Args:
17
20
  line_content: the raw location text line
18
21
 
19
22
  Returns:
20
23
  float numbers tuple (lat, lon)
21
24
  """
22
- location_text = line_content.split(":", 1)[1].split(",")[0]
23
- location_text = location_text.replace("Â", "")
24
- match = re.findall(r"[-+]?(?:\d*\.*\d+)", location_text)
25
- is_east = 1
26
- is_north = 1
27
- if "S" in location_text:
28
- is_north = -1
29
- if "W" in location_text:
30
- is_east = -1
31
- return (float(match[0]) * is_north, float(match[1]) * is_east)
25
+ location_text = line_content.split(":", 1)[1].split(",")[0].strip()
26
+ match = re.findall(r"-?[0-9]+\.[0-9]+", location_text)
27
+ return (float(match[0]), float(match[1]))
32
28
 
33
29
 
34
30
  def data_frame_parser(header_line: str, data_lines: List[List[str]]) -> pd.DataFrame:
@@ -0,0 +1,101 @@
1
+ import numpy as np
2
+ import pytest
3
+ import xarray as xr
4
+
5
+ from atmos_validation.convert_ascii import attrs_to_meta
6
+ from atmos_validation.convert_ascii.df_to_nc import assign_metadata_to_ds
7
+ from atmos_validation.convert_ascii.utils import store_netcdf
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ ("contractor", "division", "expected_contractors", "expected_divisions"),
12
+ [
13
+ ("Oceanweather Inc.", "Marine", ["Oceanweather Inc."], ["Marine"]),
14
+ ("NA", "NA", ["NA"], ["NA"]),
15
+ ("Fugro, RPS", "Fugro GB Ltd., NA", ["Fugro", "RPS"], ["Fugro GB Ltd.", "NA"]),
16
+ (
17
+ "Fugro, RPS",
18
+ "NA, RPS Group Inc.",
19
+ ["Fugro", "RPS"],
20
+ ["NA", "RPS Group Inc."],
21
+ ),
22
+ ("Fugro, RPS", "NA, NA", ["Fugro", "RPS"], ["NA", "NA"]),
23
+ ("Fugro, RPS", "", ["Fugro", "RPS"], []),
24
+ ("Fugro, RPS", None, ["Fugro", "RPS"], []),
25
+ ],
26
+ )
27
+ def test_ascii_contractors_are_converted_to_lists(
28
+ tmp_path,
29
+ monkeypatch,
30
+ contractor,
31
+ division,
32
+ expected_contractors,
33
+ expected_divisions,
34
+ ):
35
+ attrs_map = {
36
+ "comments": "Comments",
37
+ "contractor": "Contractor/Data Responsible",
38
+ "contractor_division": "Contractor Division",
39
+ "data_history": "History",
40
+ "final_reports": "Final reports",
41
+ "project_name": "Project",
42
+ "qc_provider": "QC provider",
43
+ "averaging_period": "Averaging period",
44
+ "data_usability": "Data usability",
45
+ "instrument_types": "Instrument types",
46
+ "instrument_specifications": "Instrument specifications",
47
+ "installation_type": "Installation type",
48
+ "country": "Country",
49
+ "location": "Location",
50
+ "mooring_name": "Mooring name",
51
+ "total_water_depth": "Total water depth",
52
+ }
53
+ monkeypatch.setattr(attrs_to_meta, "get_encoding", lambda _: "utf-8")
54
+ monkeypatch.setattr(attrs_to_meta, "load_attrs_map", lambda _: attrs_map)
55
+ source = tmp_path / "measurement.txt"
56
+ source.write_text(
57
+ "\n".join(
58
+ [
59
+ "% Comments: processed",
60
+ f"% Contractor/Data Responsible: {contractor}",
61
+ *(
62
+ [f"% Contractor Division: {division}"]
63
+ if division is not None
64
+ else []
65
+ ),
66
+ "% History: Original data",
67
+ "% Final reports: NA",
68
+ "% Project: Example",
69
+ "% QC provider: Example QC",
70
+ "% Averaging period: 10 min",
71
+ "% Data usability: usable",
72
+ "% Instrument types: sensor",
73
+ "% Instrument specifications: specification",
74
+ "% Installation type: fixed",
75
+ "% Country: NO",
76
+ "% Location: North Sea",
77
+ "% Mooring name: Example",
78
+ "% Total water depth: 100 m",
79
+ ]
80
+ ),
81
+ encoding="utf-8",
82
+ )
83
+
84
+ metadata = attrs_to_meta.parse_measurement_attrs(str(source), "unused")
85
+
86
+ assert metadata.contractor == expected_contractors
87
+ assert metadata.contractor_division == expected_divisions
88
+
89
+ ds = xr.Dataset(coords={"Time": [np.datetime64("2026-01-01", "ns")]})
90
+ assign_metadata_to_ds(str(source), ds)
91
+ assert ds.attrs["contractor"] == expected_contractors
92
+ assert ds.attrs["contractor_division"] == expected_divisions
93
+
94
+ path = store_netcdf(str(source), ds)
95
+ with xr.open_dataset(path, engine="h5netcdf") as reopened:
96
+ for key, expected in (
97
+ ("contractor", expected_contractors),
98
+ ("contractor_division", expected_divisions),
99
+ ):
100
+ actual = reopened.attrs[key]
101
+ assert ([actual] if isinstance(actual, str) else list(actual)) == expected
@@ -0,0 +1,15 @@
1
+ from ..parsers import parse_location_text
2
+
3
+
4
+ def test_parse_location_text_north_east():
5
+ """Tests parsing a north-east location line into signed (lat, lon) floats"""
6
+ lat, lon = parse_location_text("% Location: 60.3589N 70.8764E, WGS84")
7
+ assert lat == 60.3589
8
+ assert lon == 70.8764
9
+
10
+
11
+ def test_parse_location_text_south_west():
12
+ """Tests parsing a south-west location line (negative sign) into signed (lat, lon) floats"""
13
+ lat, lon = parse_location_text("% Location: -60.3589N -70.8764E, WGS84")
14
+ assert lat == -60.3589
15
+ assert lon == -70.8764
@@ -1,3 +1,4 @@
1
+ from .contractor import *
1
2
  from .data_usability_level import *
2
3
  from .data_usability_levels import *
3
4
  from .dim_constants import *
@@ -0,0 +1,73 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import BaseModel, Field, StringConstraints, field_validator
4
+
5
+ ContractorName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
6
+
7
+
8
+ class Contractor(BaseModel, extra="forbid"):
9
+ contractor: ContractorName
10
+ divisions: list[ContractorName] | None = Field(
11
+ default=None,
12
+ description="Allowed divisions. Null or an empty list means no divisions are configured.",
13
+ )
14
+
15
+ @field_validator("divisions")
16
+ @classmethod
17
+ def validate_unique_divisions(cls, divisions: list[str] | None) -> list[str] | None:
18
+ if divisions is not None and len(divisions) != len(set(divisions)):
19
+ raise ValueError("Division names must be unique within a contractor")
20
+ return divisions
21
+
22
+
23
+ class Contractors(BaseModel):
24
+ configs: list[Contractor]
25
+
26
+ @field_validator("configs")
27
+ @classmethod
28
+ def validate_unique_contractors(cls, configs: list[Contractor]) -> list[Contractor]:
29
+ names = [entry.contractor for entry in configs]
30
+ if len(names) != len(set(names)):
31
+ raise ValueError("Contractor names must be unique")
32
+ return configs
33
+
34
+
35
+ def validate_contractor_metadata(
36
+ contractor_names: list[str],
37
+ contractor_divisions: list[str],
38
+ configs: list[Contractor],
39
+ ) -> list[str]:
40
+ """Validate contractor names and their positionally corresponding divisions."""
41
+ configs_by_name = {config.contractor: config for config in configs}
42
+ errors = [
43
+ f'Contractor "{name}" is not in the allowed list.'
44
+ for name in contractor_names
45
+ if name.upper() != "NA" and name not in configs_by_name
46
+ ]
47
+
48
+ if not contractor_names:
49
+ return [
50
+ f'Division "{division}" is not allowed without a contractor.'
51
+ for division in contractor_divisions
52
+ if division.upper() != "NA"
53
+ ]
54
+ if not contractor_divisions:
55
+ return errors
56
+ if len(contractor_names) != len(contractor_divisions):
57
+ errors.append(
58
+ "Contractor and contractor_division lists must have the same length "
59
+ "when divisions are provided. Use NA for unspecified divisions."
60
+ )
61
+ return errors
62
+
63
+ for name, division in zip(contractor_names, contractor_divisions):
64
+ if division.upper() == "NA":
65
+ continue
66
+ config = configs_by_name.get(name)
67
+ allowed_divisions = (config.divisions or []) if config is not None else []
68
+ if division not in allowed_divisions:
69
+ errors.append(
70
+ f'Division "{division}" is not allowed for contractor "{name}". '
71
+ f"Allowed values: {sorted(allowed_divisions)}"
72
+ )
73
+ return errors
@@ -23,7 +23,8 @@ class CommonMetadata(BaseModel):
23
23
  model_config = ConfigDict(use_enum_values=True)
24
24
 
25
25
  comments: Union[List[str], str]
26
- contractor: str
26
+ contractor: Union[List[str], str] = Field(default_factory=list)
27
+ contractor_division: Union[List[str], str] = Field(default_factory=list)
27
28
  classification_level: ClassificationLevel = Field(
28
29
  default=ClassificationLevel.INTERNAL
29
30
  )
@@ -66,4 +67,4 @@ class MeasurementMetadata(CommonMetadata):
66
67
  location: Union[str, List[str]]
67
68
  mooring_name: str
68
69
  source_file: str
69
- total_water_depth: Union[str, float]
70
+ total_water_depth: str
@@ -12,6 +12,7 @@ from .header_position import HeaderPosition
12
12
  class HeaderMetaData:
13
13
  number_of_header_lines: int
14
14
  contractor: str
15
+ contractor_division: str
15
16
  final_report: str
16
17
  qc: str
17
18
  duration: HeaderDate
@@ -3,6 +3,7 @@ from enum import Enum
3
3
 
4
4
  class Headers(str, Enum):
5
5
  CONTRACTOR = "Contractor/Data Responsible"
6
+ CONTRACTOR_DIVISION = "Contractor Division"
6
7
  FINAL_REPORT = "Final Reports"
7
8
  QC = "QC Provider"
8
9
  DURATION = "Duration of Data"
@@ -18,6 +18,7 @@ class HeaderParser:
18
18
  errors: List[str],
19
19
  accepted_values: Optional[Set[str]] = None,
20
20
  split_by: Optional[str] = None,
21
+ optional: bool = False,
21
22
  ) -> str:
22
23
  real_valued = [
23
24
  Headers.LOCATION,
@@ -50,6 +51,8 @@ class HeaderParser:
50
51
  # No colon to separate key from value in metadata line
51
52
  errors.append(f'Missing Colon in line: "{line}"')
52
53
  # Could not find the key
54
+ if optional:
55
+ return ""
53
56
  if key in real_valued:
54
57
  errors.append(f"Metadata must include a real value for {key}")
55
58
  else:
@@ -90,6 +93,11 @@ class HeaderParser:
90
93
  errors,
91
94
  accepted_values[Headers.INSTALLATION_TYPE],
92
95
  )
96
+ contractor_division = self.get_simple_value(
97
+ Headers.CONTRACTOR_DIVISION,
98
+ errors,
99
+ optional=True,
100
+ )
93
101
 
94
102
  parameters = HeaderParameterInfoList(
95
103
  self.get_lines_from(
@@ -106,6 +114,7 @@ class HeaderParser:
106
114
  data = HeaderMetaData(
107
115
  number_of_header_lines=len(self.lines),
108
116
  contractor=self.get_simple_value(Headers.CONTRACTOR, errors),
117
+ contractor_division=contractor_division,
109
118
  final_report=self.get_simple_value(Headers.FINAL_REPORT, errors),
110
119
  qc=self.get_simple_value(Headers.QC, errors),
111
120
  duration=HeaderDate(self.get_simple_value(Headers.DURATION, errors)),
@@ -1,59 +1,59 @@
1
1
  import re
2
2
  from typing import List
3
3
 
4
- from .utils import trim_value
4
+ ALLOWED_DATUMS = [
5
+ "datum uncertain",
6
+ "WGS84",
7
+ "ED50",
8
+ "ED87",
9
+ "ETRS89",
10
+ "Tokyo",
11
+ "NAD27",
12
+ ]
13
+
14
+ # Matches "<lat>N <lon>E, <datum>", e.g. "60.3589N 70.8764E, WGS84" or
15
+ # "-60.3589N -70.8764E, WGS84". The sign carries the hemisphere; the N/E
16
+ # letters are fixed and do not flip to S/W.
17
+ LOCATION_PATTERN = re.compile(r"^-?[0-9]+\.[0-9]+N -?[0-9]+\.[0-9]+E,.+$")
5
18
 
6
19
 
7
20
  class HeaderPosition:
8
21
  def __init__(self, position_text: str):
9
- self.parse_location(self.replace_special_character(position_text))
22
+ self.parse_location(position_text)
10
23
 
11
24
  def parse_location(self, location_text: str):
12
- match = None
13
25
  regex_example = (
14
- "location should match e.g 11.574483°N 108.36825°E, WGS84 or "
15
- "11.574483°S 108.36825°W, some123 ranD0m characters"
16
- )
17
- pattern = re.compile(
18
- r"^[0-9]*\.[0-9]+°[NS] [0-9]*\.[0-9]+°[EW],.+$", re.IGNORECASE
26
+ "location should match e.g 60.3589N 70.8764E, WGS84 (north-east) or "
27
+ "-60.3589N -70.8764E, WGS84 (south-west)"
19
28
  )
20
- match = pattern.match(location_text)
29
+ match = LOCATION_PATTERN.match(location_text)
21
30
  if match is None:
22
31
  raise ValueError(
23
- f"the location string {location_text} should match the regex {pattern.pattern}. "
24
- f"valid examples: {regex_example}"
32
+ f"the location string {location_text} should match the regex "
33
+ f"{LOCATION_PATTERN.pattern}. valid examples: {regex_example}"
25
34
  )
26
35
  try:
27
- items = location_text.split(",")
36
+ items = location_text.split(",", 1)
28
37
  location_items = items[0].split(" ")
29
38
  self.location_text = location_text
30
- self.latitude = trim_value(location_items[0])
31
- self.longitude = trim_value(location_items[1])
32
- self.datum = trim_value(items[1])
39
+ self.latitude = location_items[0][:-1] # strip trailing "N"
40
+ self.longitude = location_items[1][:-1] # strip trailing "E"
41
+ self.datum = items[1].strip()
33
42
  except Exception as e:
34
43
  print(e)
35
44
 
36
45
  def get_latitude(self) -> str:
37
- return self.get_position_value(self.latitude, "S")
46
+ return self.latitude
38
47
 
39
48
  def get_longitude(self) -> str:
40
- return self.get_position_value(self.longitude, "W")
41
-
42
- def get_position_value(self, text_in: str, direction: str) -> str:
43
- index = text_in.find("°")
44
- value = text_in[0:index]
45
- if text_in[index + 1 : len(text_in)] == direction:
46
- return "-" + self.replace_special_character(value)
47
- return self.replace_special_character(value)
48
-
49
- def replace_special_character(self, text_in: str) -> str:
50
- return text_in.replace("�", "°").replace("Â", "°")
49
+ return self.longitude
51
50
 
52
51
  def validate(self) -> List[str]:
53
52
  messages = []
54
53
  try:
55
54
  messages = messages + self.validate_latitude()
56
55
  messages = messages + self.validate_longitude()
56
+ messages = messages + self.validate_datum()
57
57
  except Exception:
58
58
  messages.append("Location could not be parsed")
59
59
  return messages
@@ -64,6 +64,14 @@ class HeaderPosition:
64
64
  def validate_longitude(self) -> List[str]:
65
65
  return self.validate_item(self.get_longitude(), "Longitude", -180, 180)
66
66
 
67
+ def validate_datum(self) -> List[str]:
68
+ if self.datum not in ALLOWED_DATUMS:
69
+ return [
70
+ f'Datum "{self.datum}" is not in the allowed list. '
71
+ f"Allowed values: {ALLOWED_DATUMS}"
72
+ ]
73
+ return []
74
+
67
75
  def validate_item(
68
76
  self, value: str, position: str, lower: int, upper: int
69
77
  ) -> List[str]:
@@ -72,5 +80,5 @@ class HeaderPosition:
72
80
  messages.append(position + " could not be parsed")
73
81
  value_f = float(value)
74
82
  if value_f < lower or value_f > upper:
75
- messages.append(position + " should be between +-90")
83
+ messages.append(f"{position} should be between {lower} and {upper}")
76
84
  return messages
@@ -1,9 +1,10 @@
1
1
  import sys
2
2
  from typing import List, Optional, Tuple
3
3
 
4
+ from ..schemas import validate_contractor_metadata
4
5
  from .header_parser import HeaderMetaData, HeaderParser
5
6
  from .row_parser import RowParser
6
- from .utils import load_parameter_configs
7
+ from .utils import load_contractor_configs, load_parameter_configs
7
8
  from .validate_result import ValidateMeasurementResult, ValidateResult
8
9
 
9
10
  DOCSTRING = """
@@ -51,6 +52,9 @@ def main():
51
52
 
52
53
  messages = header_info.validate()
53
54
  messages += validate_with_parameter_configs(header_info)
55
+ messages += validate_contractor_header(
56
+ header_info.contractor, header_info.contractor_division
57
+ )
54
58
  if messages:
55
59
  print(ValidateMeasurementResult(ValidateResult.ERROR, messages))
56
60
  sys.exit(1)
@@ -110,3 +114,18 @@ def validate_with_parameter_configs(header_info: HeaderMetaData) -> List[str]:
110
114
  f"The unit {unit} is incorrect for base parameter {key}. The unit should be {cfg.units}"
111
115
  )
112
116
  return messages
117
+
118
+
119
+ def validate_contractor_header(contractor: str, contractor_division: str) -> List[str]:
120
+ contractor_names = [name.strip() for name in contractor.split(",")]
121
+ raw_divisions = contractor_division.strip()
122
+ divisions = (
123
+ []
124
+ if not raw_divisions
125
+ else [division.strip() for division in raw_divisions.split(",")]
126
+ )
127
+ return validate_contractor_metadata(
128
+ contractor_names,
129
+ divisions,
130
+ load_contractor_configs(),
131
+ )
@@ -0,0 +1,137 @@
1
+ from importlib import import_module
2
+
3
+ import pytest
4
+
5
+ from atmos_validation.schemas import Contractor
6
+ from atmos_validation.validate_ascii import validate_contractor_header
7
+ from atmos_validation.validate_ascii.header_parser import HeaderParser
8
+
9
+ ascii_main = import_module("atmos_validation.validate_ascii.main")
10
+
11
+
12
+ def test_ascii_contractor_and_multiple_divisions_are_rejected(monkeypatch):
13
+ monkeypatch.setattr(
14
+ ascii_main,
15
+ "load_contractor_configs",
16
+ lambda: [
17
+ Contractor(
18
+ contractor="Oceanweather Inc.",
19
+ divisions=["Marine", "Offshore", "Wind", "Wave", "Current"],
20
+ )
21
+ ],
22
+ )
23
+
24
+ errors = validate_contractor_header(
25
+ "Oceanweather Inc.", "Marine, Offshore, Wind, Wave, Current"
26
+ )
27
+
28
+ assert len(errors) == 1
29
+ assert "same length" in errors[0]
30
+
31
+
32
+ def test_ascii_unknown_contractor_is_rejected(monkeypatch):
33
+ monkeypatch.setattr(
34
+ ascii_main,
35
+ "load_contractor_configs",
36
+ lambda: [Contractor(contractor="Oceanweather Inc.", divisions=["Marine"])],
37
+ )
38
+
39
+ errors = validate_contractor_header("Unknown Contractor", "")
40
+
41
+ assert len(errors) == 1
42
+ assert "Unknown Contractor" in errors[0]
43
+
44
+
45
+ @pytest.mark.parametrize("division", ["NA", "Marine"])
46
+ def test_ascii_na_contractor_means_no_contractor(monkeypatch, division):
47
+ monkeypatch.setattr(
48
+ ascii_main,
49
+ "load_contractor_configs",
50
+ lambda: [Contractor(contractor="Oceanweather Inc.", divisions=["Marine"])],
51
+ )
52
+
53
+ errors = validate_contractor_header("NA", division)
54
+
55
+ if division == "Marine":
56
+ assert len(errors) == 1
57
+ assert 'Division "Marine"' in errors[0]
58
+ else:
59
+ assert errors == []
60
+
61
+
62
+ @pytest.mark.parametrize("configured_divisions", [None, [], ["Marine"]])
63
+ def test_ascii_na_division_is_not_validated_as_a_division(
64
+ monkeypatch, configured_divisions
65
+ ):
66
+ monkeypatch.setattr(
67
+ ascii_main,
68
+ "load_contractor_configs",
69
+ lambda: [
70
+ Contractor(contractor="Oceanweather Inc.", divisions=configured_divisions)
71
+ ],
72
+ )
73
+
74
+ assert validate_contractor_header("Oceanweather Inc.", "NA") == []
75
+
76
+
77
+ def test_ascii_division_not_allowed_by_selected_contractor_is_rejected(monkeypatch):
78
+ monkeypatch.setattr(
79
+ ascii_main,
80
+ "load_contractor_configs",
81
+ lambda: [
82
+ Contractor(contractor="Oceanweather Inc.", divisions=["Marine"]),
83
+ Contractor(contractor="Example Contractor", divisions=None),
84
+ ],
85
+ )
86
+
87
+ errors = validate_contractor_header("Example Contractor", "Marine")
88
+
89
+ assert len(errors) == 1
90
+ assert "Marine" in errors[0]
91
+
92
+
93
+ @pytest.mark.parametrize(
94
+ ("divisions", "error_count"),
95
+ [
96
+ ("Marine, Offshore", 0),
97
+ ("Marine, NA", 0),
98
+ ("NA, Offshore", 0),
99
+ ("NA, NA", 0),
100
+ ("", 0),
101
+ ("Offshore, Marine", 2),
102
+ ("Marine", 1),
103
+ ("NA", 1),
104
+ ("Marine, Offshore, Marine", 1),
105
+ ],
106
+ )
107
+ def test_ascii_divisions_match_contractors_by_position(
108
+ monkeypatch, divisions, error_count
109
+ ):
110
+ monkeypatch.setattr(
111
+ ascii_main,
112
+ "load_contractor_configs",
113
+ lambda: [
114
+ Contractor(contractor="Oceanweather Inc.", divisions=["Marine"]),
115
+ Contractor(contractor="Example Contractor", divisions=["Offshore"]),
116
+ ],
117
+ )
118
+
119
+ errors = validate_contractor_header(
120
+ "Oceanweather Inc., Example Contractor", divisions
121
+ )
122
+
123
+ assert len(errors) == error_count
124
+ if divisions == "Offshore, Marine":
125
+ assert 'contractor "Oceanweather Inc."' in errors[0]
126
+ assert 'contractor "Example Contractor"' in errors[1]
127
+
128
+
129
+ def test_contractor_division_header_is_optional():
130
+ errors = []
131
+
132
+ value = HeaderParser([]).get_simple_value(
133
+ "Contractor Division", errors, optional=True
134
+ )
135
+
136
+ assert value == ""
137
+ assert errors == []