Repository: obspy/obspy

Issue:
NLLoc rejected locations & phases added in nlloc.core
NonLinLoc outputs hyp files for each location, they can be read into an obpsy catalog object ([source code](https://docs.obspy.org/_modules/obspy/io/nlloc/core.html#_read_single_hypocenter)). 
**Currently obspy reads in rejected locations and rejected arrivals in the same way as those that are not rejected.**

obspy 1.2.2, python 3.7.3 - install from conda-forge in an anaconda environment on Ubuntu 18.04.4
using nonlinloc 7.0
Can't find any mention of this in other issues raised previously

# Origins
I have seen two types of locations in hyp files (there may be others). Currently both are processed in the same way:
- a successful location. The first line of each hyp file for a successful location reads something like:
> NLLOC "./locs/test_loc/test.20220101.000000.gri0" "LOCATED" "Location completed."
- a location which was completed but was then rejected for being on the boundary of the search region. The first line of each hyp file for a rejected locations reads something like:
> NLLOC "./locs/test_loc/test.20220101.000000.gri0" "REJECTED" "WARNING: max prob location on grid boundary 10, rejecting location."

I have attached two example hyp files (one for a successful location [test_located.txt](https://github.com/obspy/obspy/files/8084723/test_located.txt) and one for a rejected location [test_rejected.txt](https://github.com/obspy/obspy/files/8084727/test_rejected.txt)).

I don't think the fact that NLLoc rejects the location is currently marked in the created origin in any way - it probably should be? Maybe they shouldn't be included at all.

# Arrivals
The hyp file lists the phases related to the location: both those which are used, and those which are rejected are listed. Similarly to the above, currently obspy reads in all of these as arrivals for each origin. 

The fact that NLLoc rejected the arrival is not specifically marked, but rejected arrivals seem to have:
- azimuth 359.0
- distance 0.0
- takeoff_angle -1.0
- time residual 0.0
- time_weight 0.0

and you can identify the origins with the problem:
`if len(origin.arrivals) == origin.quality.associated_phase_count and len(origin.arrivals) != origin.quality.used_phase_count:`

An example of a hyp file with rejected arrivals can be found in [test_rejected.txt](https://github.com/obspy/obspy/files/8084727/test_rejected.txt) (note: you do also get rejected phases in hyp files for locations which are not rejected).


Discussion:
> I don't think the fact that NLLoc rejects the location is currently marked in the created origin in any way - it probably should be? Maybe they shouldn't be included at all.

Well, Nonlinloc returns full location output and only mentions "rejected" as text in the comment line.. so I dont see why we shouldn't read that file. I'd probably suggest setting the [event type](https://docs.obspy.org/master/packages/obspy.core.event.header.html#obspy.core.event.header.EventType) to "not reported" and maybe the [event certainty](https://docs.obspy.org/master/packages/obspy.core.event.header.html#obspy.core.event.header.EventTypeCertainty) to "suspected".
Indeed it looks like right now any text in the initial `NLLOC ....` line isn't parsed, we could parse that into a `Comment` maybe.

> lists the phases related to the location: both those which are used, and those which are rejected are listed

I only see one "rejected" phase and nonlinloc states the "weight" as `0.0` which also gets stored in `Arrival.time_weight`, so you can actually tell the "rejected" lines I believe. So, I think we should be fine regarding this matter?
n.b.: Note that that phase states the station coordinates as -1000000000000, so there might be an issue with your station coordinates?
But yes.. if we could confirm with @alomax that such "rejected" phases always have the same "bogus" values, we could parse it as a pick but not create an Arrival from it maybe.
> n.b.: Note that that phase states the station coordinates as -1000000000000, so there might be an issue with your station coordinates?

Yes - in this example MAVZ is a station picked but not defined in the NLLoc set up.  The other instance I know of when picks are rejected is when the phase marker is one which is not being used - e.g. when S picks are being rejected, as below (copied from a different hyp file):
> BEEZ   ?    Z    ? S      ? 20201209 0634   50.1568 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00 >    0.0000    0.0000    0.0000   42.6331   56.3267   -0.5720    0.0000   0.00 359.0  -1.0  0     0.0000

As you say, it could be that reading in `Arrival.time_weight = 0` is sufficient.

Hi all,

For arrivals, it is correct that station coordinates as -1000000000000 is a good indicator that the phase reading was rejected on input - this is usually (always?) because there is no travel-time grid file available or no station location (GLOBAL mode) for this station and phase:

`NC_PTA       ?    Z    e P      ? 20220130 2047   45.1000 GAU  1.00e-01 -1.00e+00 -1.00e+00 -1.00e+00    1.0000 >    0.0000    0.0000    0.0000 -100000000000000000000.0000 -100000000000000000000.0000 -100000000000000000000.0000    0.0000   0.00 359.0  -1.0  0     0.0000    0.0000
`

Zero-weight, however, may also occur for valid station/phases for which there is an available travel-time grid. Zero weight can be (somewhat ambiguously): 

- With GAU_ANALYTIC the weight is prior, and can occur for a reading that has very large pick or travel-time uncertainty.
- With EDT the weight is posterior and can occur for a reading that has very large residual at the maximum likelihood hypocenter. The above prior case can also occur with EDT
- When LOCEXCLUDE or LOCINCLUDE is used to exclude or explicitly include sets of station/phases from location, the weight will be zero, but residual non-zero for excluded station/phases.

"The other instance I know of when picks are rejected is when the phase marker is one which is not being used - e.g. when S picks are being rejected, as below"

How are you rejecting S picks in this case?

Anthony
It as a while ago I was messing aorund with only using P arrivals, so I have gone back and checked. The above instance was with no S velocity or travel time grids built, but -1 for `VpVsRatio` in the `LOCMETH` line.

Just tested  - when I mapped S to $ `LOCPHASEID $ S` then S arrivals were not read in and do not appear in the hyp file.

With `LOCPHASEID  S   S s G SN SG`
> ... 43 observations read, 43 will be used for location (./locs/20201030_find_s_waves_loc.20191027.064125).

With `LOCPHASEID $ S`
> ... 23 observations read, 23 will be used for location (./locs/20201030_find_s_waves_loc.20191027.064125).
Hello @erhmestel

Thanks for the details. Yes, this is exactly the behavior I see. "$" is a special phase code to completely reject a phase pick observation.

Anthony

Resolving patch:
diff --git a/obspy/io/nlloc/__init__.py b/obspy/io/nlloc/__init__.py
--- a/obspy/io/nlloc/__init__.py
+++ b/obspy/io/nlloc/__init__.py
@@ -35,7 +35,7 @@
                                    version='NLLoc:v6.02.07')
  preferred_origin_id: ResourceIdentifier(id="smi:local/...")
                 ---------
-            comments: 1 Elements
+            comments: 2 Elements
                picks: 5 Elements
              origins: 1 Elements
 
@@ -54,7 +54,7 @@
                                    creation_time=UTCDateTime(2013, 6, 21, ...),
                                    version='NLLoc:v6.02.07')
                 ---------
-            comments: 1 Elements
+            comments: 2 Elements
             arrivals: 5 Elements
 
 
@@ -113,8 +113,19 @@
      creation_info: CreationInfo(creation_time=UTCDateTime(2014, 10, 17,
 16, 30, 8), version='NLLoc:v6.00.0')
     ---------
-          comments: 1 Elements
+          comments: 2 Elements
           arrivals: 8 Elements
+
+If NonLinLoc reports the location run as "ABORTED", "IGNORED" or "REJECTED",
+the evaluation status of the origin will be set to "rejected" (which otherwise
+is unset, i.e. ``None``). Further information might be found in the "NLLOC"
+info header line of the hyp file that gets stored in event and origin comments:
+
+>>> cat = read_events("/path/to/nlloc_rejected.hyp", format="NLLOC_HYP")
+>>> print(cat[0].origins[0].evaluation_status)
+rejected
+>>> print(cat[0].comments[1].text)  # doctest: +ELLIPSIS
+NLLOC ... "REJECTED" ... max prob location on grid boundary 10, rejecting ...
 """
 if __name__ == '__main__':
     import doctest
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
@@ -241,6 +241,8 @@ def _read_single_hypocenter(lines, coordinate_converter, original_picks,
     hor_unc, min_hor_unc, max_hor_unc, hor_unc_azim = \
         map(float, line.split()[1:9:2])
 
+    nlloc_info_line = 'NLLOC ' + lines['NLLOC']
+
     # assign origin info
     event = Event()
     o = Origin()
@@ -251,7 +253,10 @@ def _read_single_hypocenter(lines, coordinate_converter, original_picks,
     ou = o.origin_uncertainty
     oq = o.quality
     o.comments.append(Comment(text=stats_info_string, force_resource_id=False))
+    o.comments.append(Comment(text=nlloc_info_line, force_resource_id=False))
     event.comments.append(Comment(text=comment, force_resource_id=False))
+    event.comments.append(Comment(text=nlloc_info_line,
+                                  force_resource_id=False))
 
     # SIGNATURE field's first item is LOCSIG, which is supposed to be
     # 'Identification of an individual, institiution or other entity'
@@ -265,6 +270,13 @@ def _read_single_hypocenter(lines, coordinate_converter, original_picks,
                                    version=version,
                                    author=signature)
 
+    # nlloc writes location status in "NLLOC" line
+    # char location status LOCATED, ABORTED, IGNORED, REJECTED
+    # set evaluation status to "rejected" if it is anything but LOCATED
+    nlloc_location_status = lines['NLLOC'].split()[1].strip('\'"')
+    if nlloc_location_status in ('ABORTED', 'IGNORED', 'REJECTED'):
+        o.evaluation_status = 'rejected'
+
     # negative values can appear on diagonal of covariance matrix due to a
     # precision problem in NLLoc implementation when location coordinates are
     # large compared to the covariances.


Test patch:
diff --git a/obspy/io/nlloc/tests/data/nlloc_custom.qml b/obspy/io/nlloc/tests/data/nlloc_custom.qml
--- a/obspy/io/nlloc/tests/data/nlloc_custom.qml
+++ b/obspy/io/nlloc/tests/data/nlloc_custom.qml
@@ -10,6 +10,9 @@
       <comment>
         <text>NonLinLoc OctTree Location</text>
       </comment>
+      <comment>
+        <text>NLLOC "./nlloc.20100527.165625.grid0" "LOCATED" "Location completed."</text>
+      </comment>
       <creationInfo>
         <author>Megies LMU Munich</author>
         <creationTime>2014-10-17T16:30:08.000000Z</creationTime>
@@ -51,6 +54,9 @@
             <text>Note: Depth/Latitude/Longitude errors are calculated from covariance matrix as 1D marginal (Lon/Lat errors as great circle degrees) while OriginUncertainty min/max horizontal errors are calculated from 2D error ellipsoid and are therefore seemingly higher compared to 1D errors. Error estimates can be reconstructed from the following original NonLinLoc error statistics line:
 STATISTICS ExpectX 4473.68 Y 5323.29 Z 4.59501  CovXX 0.0282621 XY -0.0053866 XZ 0.0043871 YY 0.0191034 YZ 0.00503624 ZZ 0.036713 EllAz1  206.782 Dip1  16.4026 Len1  0.227982 Az2  300.149 Dip2  11.2855 Len2  0.327468 Len3  3.709256e-01</text>
         </comment>
+        <comment>
+          <text>NLLOC "./nlloc.20100527.165625.grid0" "LOCATED" "Location completed."</text>
+        </comment>
         <creationInfo>
           <author>Megies LMU Munich</author>
           <creationTime>2014-10-17T16:30:08.000000Z</creationTime>
diff --git a/obspy/io/nlloc/tests/data/nlloc_rejected.hyp b/obspy/io/nlloc/tests/data/nlloc_rejected.hyp
new file mode 100644
--- /dev/null
+++ b/obspy/io/nlloc/tests/data/nlloc_rejected.hyp
@@ -0,0 +1,23 @@
+NLLOC "./locs/20211214_2020-12-09_manual_loc/20211214_2020-12-09_manual.20201209.163708.grid0" "REJECTED" "WARNING: max prob location on grid boundary 10, rejecting location."
+SIGNATURE "   obs:./picks/taupo_2020-12-09_manual_PickFiles/Picks_2020m000124.dat   NLLoc:v7.00.00(27Oct2017)  run:14Dec2021 22h40m22"
+COMMENT ""
+GRID  240 240 80  0 0 -4  0.5 0.5 0.5 PROB_DENSITY
+SEARCH OCTREE nInitial 25000 nEvaluated 49928 smallestNodeSide 0.074688/0.074688/0.125000 oct_tree_integral 3.450765e+01 scatter_volume 3.450765e+01
+HYPOCENTER  x 0.0373438 y 2.42734 z 35.3125  OT 3.0582  ix -1 iy -1 iz -1 MAXIMUM_LIKELIHOOD
+GEOGRAPHIC  OT 2020 12 09  16 37 3.058205  Lat -39.278154 Long 175.300434 Depth 35.312500
+QUALITY  Pmax 3.39278e+150 MFmin 29.4569 MFmax 29.5213 RMS 0.645344 Nphs 53 Gap 263.731 Dist 4.14012 Mamp -9.90 0 Mdur -9.90 0
+VPVSRATIO  VpVsRatio 1.7759  Npair 24  Diff 7.8165
+STATISTICS  ExpectX 0.987252 Y 4.05843 Z 32.9989  CovXX 0.914195 XY -0.152226 XZ 0.25015 YY 6.22208 YZ -2.06938 ZZ 5.98514 EllAz1  89.3552 Dip1  -2.55491 Len1  1.78372 Az2  2.05253 Dip2  46.5237 Len2  3.77301 Len3  5.375995e+00
+STAT_GEOG  ExpectLat -39.263474 Long 175.311476 Depth 32.998901
+TRANSFORM  SIMPLE LatOrig -39.300000  LongOrig 175.300000  RotCW 0.000000
+QML_OriginQuality  assocPhCt 55  usedPhCt 53  assocStaCt -1  usedStaCt 29  depthPhCt -1  stdErr 0.645344  azGap 263.731  secAzGap 263.731  gtLevel -  minDist 4.14012 maxDist 107.432 medDist 38.7315
+QML_OriginUncertainty  horUnc -1  minHorUnc 1.44659  maxHorUnc 3.78429  azMaxHorUnc 178.359
+QML_ConfidenceEllipsoid  semiMajorAxisLength 5.376  semiMinorAxisLength 1.78372  semiIntermediateAxisLength 3.77301  majorAxisPlunge 43.3623  majorAxisAzimuth 176.94  majorAxisRotation 227.074
+FOCALMECH  Hyp  -39.278154 175.300434 35.312500 Mech  0 0 0 mf  0 nObs 0
+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
+TLZ    ?    Z    ? P      ? 20201209 1637   19.3339 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00 >   17.6946   -1.4189    0.8293   20.7446  107.8444   -0.6600  107.4316  11.11  40.0 200.0  0     0.0000
+TLZ    ?    N    ? S      ? 20201209 1637   32.6579 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00 >   32.3436   -2.7439    0.2918   20.7446  107.8444   -0.6600  107.4316  11.11  40.0 200.0  0     0.0000
+MAVZ   ?    Z    ? P      d 20201209 1637   10.7803 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00 >    0.0000    0.0000    0.0000 -100000000000000000000.0000 -100000000000000000000.0000 -100000000000000000000.0000    0.0000   0.00 359.0  -1.0  0     0.0000
+MAVZ   ?    ?    ? S      ? 20201209 1637   17.3001 GAU -1.00e+00 -1.00e+00 -1.00e+00 -1.00e+00 >    0.0000    0.0000    0.0000 -100000000000000000000.0000 -100000000000000000000.0000 -100000000000000000000.0000    0.0000   0.00 359.0  -1.0  0     0.0000
+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
@@ -348,3 +348,22 @@ def test_reading_nlloc_v7_hyp_file(self):
         path = str(self.datapath / 'nlloc_v7.hyp')
         cat = read_nlloc_hyp(path)
         assert cat[0].origins[0].arrivals[0].azimuth == 107.42
+        # compare test_rejected_origin test case
+        assert cat[0].origins[0].evaluation_status is None
+
+    def test_rejected_origin(self):
+        """
+        Tests that we are marking rejected event/origin as such.
+        Also tests that NLLOC header line is written into comment.
+        (testing that evaluation status is left empty on "LOCATED" reported by
+        nonlinloc is tested in other test case.
+        """
+        path = str(self.datapath / 'nlloc_rejected.hyp')
+        cat = read_nlloc_hyp(path)
+        expected_comment = (
+            'NLLOC "./locs/20211214_2020-12-09_manual_loc/20211214_2020-12-09_'
+            'manual.20201209.163708.grid0" "REJECTED" "WARNING: max prob '
+            'location on grid boundary 10, rejecting location."')
+        assert cat[0].origins[0].evaluation_status == "rejected"
+        assert cat[0].origins[0].comments[1].text == expected_comment
+        assert cat[0].comments[1].text == expected_comment