Repository: obspy/obspy

Issue:
writing catalog to NLL obs doesn't handle 12 (etc) channels properly
### Avoid duplicates

- [X] I searched existing issues

### Bug Summary

Noticing that when one exports a catalog object to NLL's obs format, it converts any channel outside of `ZNEH` to a `?`. Not really an issue as NLL still handles it fine.

e.g. obspy/io/nlloc line 400 or so
```
    for pick in catalog[0].picks:
        wid = pick.waveform_id
        station = wid.station_code or "?"
        component = wid.channel_code and wid.channel_code[-1].upper() or "?"
        if component not in "ZNEH":
            component = "?"
        onset = ONSETS_REVERSE.get(pick.onset, "?")
        phase_type = pick.phase_hint or "?"
        polarity = POLARITIES_REVERSE.get(pick.polarity, "?")
```

However when reading NLL's output `.hpy` file back in, this (presumably horizontal) channel is now converted to `N` which then becomes a problem if the station's actual channels are `12`.

It seems like a trivial fix to change ` if component not in "ZNEH"` to at least  `ZNE123H` (or even `ZNE123HABCUVWTR`?) unless I am missing something. I know NLL will handle 123 but not completely sure about the more esoteric channels. 



### Code to Reproduce

_No response_

### Error Traceback

_No response_

### ObsPy Version?

1.3.1.post0+390.g3ad5eee621.obspy.master

### Operating System?

_No response_

### Python Version?

_No response_

### Installation Method?

developer installation / from source / git checkout


Discussion:
2nd issue that is a bit more serious but also an easy fix in the same file...

obspy is reading in the phase information in hyp files incorrectly. For example, the top two lines correspond to the given arrivals. 

```
PHASE ID    Ins Cmp   On Pha  FM   Date    HrMn   Sec     Err  ErrMag    Coda      Amp       Per       PriorWt  >   TTpred    Res       Weight    StaLoc(X  Y           Z)      SDist    SAzim  RAz  RDip RQual   Tcorr     TTerr
NWAO         ?    Z    ? P      ? 20221031 0502   41.3602 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00    1.0000 >   12.7040   -0.3041    1.2104  -24.3880 -103.3008   -0.3800   73.3529 107.42  40.0 200.0  0     0.0000    0.6352
NWAO         ?    ?    ? S      ? 20221031 0502   51.4602 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00    1.0000 >   22.7013   -0.2013    1.1613  -24.3880 -103.3008   -0.3800   73.3529 107.42  40.0 200.0  0     0.0000    1.1351
```
```
[Arrival
	   resource_id: ResourceIdentifier(id="smi:local/a9cfe6f2-8a0a-4030-aa61-3ec2601dd4e6")
	       pick_id: ResourceIdentifier(id="smi:local/cdffadb1-9954-4eaa-bbf9-57f1c8e23011")
	         phase: 'P'
	       azimuth: 107.42
	      distance: -0.003417422102491176
	 takeoff_angle: 40.0
	 time_residual: 12.704
	   time_weight: -0.3041,
 Arrival
	   resource_id: ResourceIdentifier(id="smi:local/c423d45d-94cb-4797-80b8-ae5cb4789a83")
	       pick_id: ResourceIdentifier(id="smi:local/f93e7b3e-0435-4c36-8f98-1fd2004093c5")
	         phase: 'S'
	       azimuth: 107.42
	      distance: -0.003417422102491176
	 takeoff_angle: 40.0
	 time_residual: 22.7013
	   time_weight: -0.2013,
```
[last.hyp.txt](https://github.com/obspy/obspy/files/10028900/last.hyp.txt)

The distances, residuals, and weights have been jumbled. Possibly obspy is following an older format version of NLL HYP file (e.g. v6? I'm using v7)

In any event the following three changes in core.py produces the expected results

```
        arrival.distance = kilometer2degrees(float(line[22]))
        arrival.time_residual = float(line[17])
        arrival.time_weight = float(line[18])
```
```
[Arrival
	   resource_id: ResourceIdentifier(id="smi:local/58af4411-5af4-4a7c-952c-4ebd6b279f0e")
	       pick_id: ResourceIdentifier(id="smi:local/366a99cb-2f4e-4598-83ea-0879e5e14987")
	         phase: 'P'
	       azimuth: 107.42
	      distance: 0.6596784782679606
	 takeoff_angle: 40.0
	 time_residual: -0.3041
	   time_weight: 1.2104,
 Arrival
	   resource_id: ResourceIdentifier(id="smi:local/887cd0db-2ab7-404c-a250-744d86b0e75b")
	       pick_id: ResourceIdentifier(id="smi:local/da2aa00c-a3dc-4059-a706-d9e1c005c944")
	         phase: 'S'
	       azimuth: 107.42
	      distance: 0.6596784782679606
	 takeoff_angle: 40.0
	 time_residual: -0.2013
	   time_weight: 1.1613,
```

continuing my deep dive into this (previously I was converting these all by hand!) so possibly more to come


### Regarding the first issue

I totally agree, this probably should be changed to just use the last character of channel code unchanged. Looking at NLLoc docs, I probably was confused by `component identification for the trace for which the time pick corresponds (i.e. Z, N, E, H)`, with "i.e." implying that it should be exactly one the mentioned characters, but probably what is meant is "e.g.". I would also toss out the capitalization, just use last character as is.

Actually, I'm looking at some of our test files from @claudiodsf (`io/nlloc/tests/data/nlloc.hyp`) and I see that it has all three letters of the channel code in this "component" field, so probably we can just do the same.

Probably just make it:
```python
    component = wid.channel_code or "?"
```


--

### Regarding the second issue

 well yeah, thats just very unfortunate to change the [NLLOC hyp file format](http://alomax.free.fr/nlloc/soft7.00/formats.html#_location_hypphs_) which is free form whitespace separated values in a breaking fashion. There apparently was one value added in the middle of the phase lines (`PriorWt`).

Original
```
PHASE ID Ins Cmp On Pha FM Date HrMn Sec Err ErrMag Coda Amp Per > TTpred Res Weight StaLoc(X Y Z) SDist SAzim RAz RDip RQual Tcorr
```

New (NLL_FORMAT_VER_2 ?)
```
PHASE ID Ins Cmp On Pha FM Date HrMn Sec Err ErrMag Coda Amp Per PriorWt > TTpred Res Weight StaLoc(X Y Z) SDist SAzim RAz RDip RQual Tcorr TTerr
```

![Screenshot from 2022-11-17 10-10-48](https://user-images.githubusercontent.com/1842780/202406197-abe4fc55-e58b-43cd-b685-79f37fc6289c.png)


Is there a way to tell from the file that it is "version 2"? Can you post one of your hyp files? If yes, then we can determine the indices of items on the phase lines from it, if not we might have to check at what position in the line this magic ">" separator is positioned to judge from that what indices to look at.
yep i was afraid of that...

I did post the ver2 hyp file above, it's easy to miss https://github.com/obspy/obspy/files/10028900/last.hyp.txt

if V2 has more elements i suppose that is one tell 
I guess we could check the NLLoc version as stated in the SIGNATURE line, I think it should always be there. But then we would need to check at what exact nonlinloc version this changed.. to be honest, I'd probably check if `"PriorWt" in line` PHASE.. or well, actually, it feels safest to just look at position of ">" magic character in the PHASE line

Resolving patch:
diff --git a/obspy/io/nlloc/core.py b/obspy/io/nlloc/core.py
--- a/obspy/io/nlloc/core.py
+++ b/obspy/io/nlloc/core.py
@@ -138,6 +138,7 @@ def _read_single_hypocenter(lines, coordinate_converter, original_picks,
     Given a list of lines (starting with a 'NLLOC' line and ending with a
     'END_NLLOC' line), parse them into an Event.
     """
+    nlloc_file_format_version = None
     try:
         # some paranoid checks..
         assert lines[0].startswith("NLLOC ")
@@ -154,6 +155,18 @@ def _read_single_hypocenter(lines, coordinate_converter, original_picks,
     for i, line in enumerate(lines):
         if line.startswith("PHASE "):
             indices_phases[0] = i
+            # determine whether it's old format or new one which has one
+            # additional item in middle. use position of magic character to
+            # find out.
+            separator_position = line.split().index('>')
+            if separator_position == 15:
+                nlloc_file_format_version = 1
+            elif separator_position == 16:
+                nlloc_file_format_version = 2
+            else:
+                msg = ("This should not happen, please open a ticket on "
+                       "github and supply your nonlinloc file for debugging")
+                raise NotImplementedError(msg)
         elif line.startswith("END_PHASE"):
             indices_phases[1] = i
 
@@ -319,11 +332,22 @@ def _read_single_hypocenter(lines, coordinate_converter, original_picks,
         channel = line[2]
         phase = line[4]
         arrival.phase = phase
-        arrival.distance = kilometer2degrees(float(line[21]))
-        arrival.azimuth = float(line[23])
-        arrival.takeoff_angle = float(line[24])
-        arrival.time_residual = float(line[16])
-        arrival.time_weight = float(line[17])
+        if nlloc_file_format_version == 1:
+            arrival.distance = kilometer2degrees(float(line[21]))
+            arrival.azimuth = float(line[23])
+            arrival.takeoff_angle = float(line[24])
+            arrival.time_residual = float(line[16])
+            arrival.time_weight = float(line[17])
+        elif nlloc_file_format_version == 2:
+            arrival.distance = kilometer2degrees(float(line[22]))
+            arrival.azimuth = float(line[24])
+            arrival.takeoff_angle = float(line[25])
+            arrival.time_residual = float(line[17])
+            arrival.time_weight = float(line[18])
+        else:
+            msg = ("This should not happen, please open a ticket on "
+                   "github and supply your nonlinloc file for debugging")
+            raise NotImplementedError(msg)
         pick = Pick()
         # have to split this into ints for overflow to work correctly
         date, hourmin, sec = map(str, line[6:9])
@@ -401,9 +425,7 @@ def write_nlloc_obs(catalog, filename, **kwargs):
     for pick in catalog[0].picks:
         wid = pick.waveform_id
         station = wid.station_code or "?"
-        component = wid.channel_code and wid.channel_code[-1].upper() or "?"
-        if component not in "ZNEH":
-            component = "?"
+        component = wid.channel_code or "?"
         onset = ONSETS_REVERSE.get(pick.onset, "?")
         phase_type = pick.phase_hint or "?"
         polarity = POLARITIES_REVERSE.get(pick.polarity, "?")


Test patch:
diff --git a/obspy/io/nlloc/tests/data/nlloc_v7.hyp b/obspy/io/nlloc/tests/data/nlloc_v7.hyp
new file mode 100644
--- /dev/null
+++ b/obspy/io/nlloc/tests/data/nlloc_v7.hyp
@@ -0,0 +1,24 @@
+NLLOC "/dev/shm/NLL/NLL.LuGxVBGQG/.20221031.050241.grid0" "LOCATED" "Location completed."
+PUBLIC_ID None
+SIGNATURE "sekiya   obs:/dev/shm/NLL/NLL.LuGxVBGQG/NLL.LuGxVBGQG.obs   NLLoc:v7.00.13(17Jan2022)   run:17Nov2022 15h49m52"
+COMMENT ""
+GRID  271 296 17  -407.434 -453.957 -1.5  3 3 3 PROB_DENSITY
+SEARCH OCTREE nInitial 40256 nEvaluated 60000 smallestNodeSide 0.744485/0.747466/0.375000 oct_tree_integral 4.782626e+03 scatter_volume 4.689266e+03
+HYPOCENTER  x -94.378 y -81.3451 z -0.5625  OT 28.9603  ix -1 iy -1 iz -1 MAXIMUM_LIKELIHOOD
+GEOGRAPHIC  OT 2022 10 31  05 02 28.960252  Lat -32.726757 Long 116.492222 Depth -0.562500
+QUALITY  Pmax 3.62877e+19 MFmin 4.87596 MFmax 5.28563 RMS 0.817355 Nphs 14 Gap 197.042 Dist 73.3529 Mamp -9.90 0 Mdur -9.90 0
+VPVSRATIO  VpVsRatio 1.77701  Npair 7  Diff 20.88
+STATISTICS  ExpectX -95.435920 Y -82.055796 Z 12.040209  CovXX 110.261 XY 8.30876 XZ 5.92278 YY 24.536 YZ -3.09884 ZZ 110.066 EllAz1  174.359 Dip1  -2.4273 Len1  9.1241 Az2  261.746 Dip2  47.0829 Len2  19.255 Len3  2.025742e+01
+STAT_GEOG  ExpectLat -32.733052 Long 116.480853 Depth 12.040209
+TRANSFORM  AZIMUTHAL_EQUIDIST RefEllipsoid WGS-84  LatOrig -32.000000  LongOrig 117.500000  RotCW 0.000000
+QML_OriginQuality  assocPhCt 14  usedPhCt 14  assocStaCt -1  usedStaCt 7  depthPhCt -1  stdErr 0.817355  azGap 197.042  secAzGap 197.042  gtLevel -  minDist 73.3529 maxDist 274.562 medDist 217.684
+QML_OriginUncertainty  horUnc -1  minHorUnc 7.38902  maxHorUnc 15.9824  azMaxHorUnc 84.5147
+QML_ConfidenceEllipsoid  semiMajorAxisLength 20.2574  semiMinorAxisLength 9.1241  semiIntermediateAxisLength 19.255  majorAxisPlunge 42.8141  majorAxisAzimuth 86.61  majorAxisRotation 322.815
+FOCALMECH  Hyp  -32.726757 116.492222 -0.562500 Mech  0 0 0 mf  0 nObs -1
+PHASE ID    Ins Cmp   On Pha  FM   Date    HrMn   Sec     Err  ErrMag    Coda      Amp       Per       PriorWt  >   TTpred    Res       Weight    StaLoc(X  Y           Z)      SDist    SAzim  RAz  RDip RQual   Tcorr     TTerr
+NWAO         ?    Z    ? P      ? 20221031 0502   41.3602 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00    1.0000 >   12.7040   -0.3041    1.2104  -24.3880 -103.3008   -0.3800   73.3529 107.42  40.0 200.0  0     0.0000    0.6352
+NWAO         ?    ?    ? S      ? 20221031 0502   51.4602 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00    1.0000 >   22.7013   -0.2013    1.1613  -24.3880 -103.3008   -0.3800   73.3529 107.42  40.0 200.0  0     0.0000    1.1351
+KLBR         ?    Z    ? P      ? 20221031 0502   56.8602 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00    1.0000 >   27.3100    0.5900    1.0455   24.1420   45.4457   -0.3200  173.5594  43.07  40.0 200.0  0     0.0000    1.3655
+END_PHASE
+END_NLLOC
+
diff --git a/obspy/io/nlloc/tests/test_core.py b/obspy/io/nlloc/tests/test_core.py
--- a/obspy/io/nlloc/tests/test_core.py
+++ b/obspy/io/nlloc/tests/test_core.py
@@ -6,6 +6,7 @@
 import re
 import unittest
 import warnings
+from pathlib import Path
 
 from obspy import UTCDateTime, read_events
 from obspy.core.inventory import Inventory, Network, Station, Channel
@@ -37,9 +38,9 @@ class NLLOCTestCase(unittest.TestCase):
     Test suite for obspy.io.nlloc
     """
     def setUp(self):
-        self.path = os.path.dirname(os.path.abspath(inspect.getfile(
-            inspect.currentframe())))
-        self.datapath = os.path.join(self.path, "data")
+        self.path = Path(os.path.dirname(os.path.abspath(inspect.getfile(
+            inspect.currentframe()))))
+        self.datapath = self.path / "data"
 
     def test_write_nlloc_obs(self):
         """
@@ -337,3 +338,13 @@ def test_issue_2222(self):
         pick1, pick2 = cat[0].picks[-1], cat[0].picks[-2]
         self.assertEqual(pick1.time.hour, 0)
         self.assertEqual(pick2.time.second, 0)
+
+    def test_reading_nlloc_v7_hyp_file(self):
+        """
+        Tests that we are getting the positioning of items in phase lines
+        right. Values for arrivals are shifted by one index to the right in hyp
+        files written by newer nonlinloc versions, see #3223
+        """
+        path = str(self.datapath / 'nlloc_v7.hyp')
+        cat = read_nlloc_hyp(path)
+        assert cat[0].origins[0].arrivals[0].azimuth == 40