Repository: obspy/obspy

Issue:
Input unit with a metrix prefix not implemented in remove_response
### Avoid duplicates

- [X] I searched existing issues

### Bug Summary

I'm not quite sure if it's a bug or feature. Remove_response cannot recognize the correct scale for a unit with a metric prefix (such as nm/s). The description on the remove_response webpage says: "VEL velocity, output unit is meters/second" but in reality, it remains in the original prefixed unit.

I can see that in [Response._call_eval_resp_for_frequencies](https://docs.obspy.org/_modules/obspy/core/inventory/response.html#Response._call_eval_resp_for_frequencies) it is already prepared to scale a response according to the input unit:

`            # Scale factor with the same logic as evalresp.` 
`             if key in ["CM/S**2", "CM/S", "CM/SEC", "CM"]:` 
`                  scale_factor[0] = 1.0E2` 
`             elif key in ["MM/S**2", "MM/S", "MM/SEC", "MM"]:` 
`                 scale_factor[0] = 1.0E3` 
`             elif key in ["NM/S**2", "NM/S", "NM/SEC", "NM"]:` 
`                 scale_factor[0] = 1.0E9`

but is not used finally:
`            # XXX: Check if this is really not needed.`
`            # output *= scale_factor[0]`

### Code to Reproduce

_No response_

### Error Traceback

_No response_

### ObsPy Version?

1.4.0

### Operating System?

Windows

### Python Version?

3.10.8

### Installation Method?

conda


Discussion:
Can you add a self-contained example please?
```python
from obspy.clients.fdsn import Client
from obspy import UTCDateTime

net = 'SL'
stat = 'BOJS'
chan = 'LHZ'
torigin = UTCDateTime('2023-10-09T18:23:09')
client = Client('ODC')

inv_nms = client.get_stations(network=net, station=stat, channel=chan, endafter=torigin, level='response')
print(inv_nms.networks[0].stations[0].channels[0].response)    # input unit: nm/s

# create the same inventory but with the input unit: m/s 
inv_ms = inv_nms.copy()
sensitivity = inv_ms.networks[0].stations[0].channels[0].response.instrument_sensitivity
sensitivity.input_units = 'm/s'
sensitivity.value *= 1e9
gain_0 = inv_ms.networks[0].stations[0].channels[0].response.response_stages[0]
gain_0.input_units = 'm/s'
gain_0.stage_gain *= 1e9
print(inv_ms.networks[0].stations[0].channels[0].response)

# download a signal & attach the response
tr_nms = client.get_waveforms(network=net, station=stat, location='*', channel=chan, 
                              starttime=torigin, endtime=torigin+600)[0]
tr_ms = tr_nms.copy()
tr_nms.attach_response(inv_nms)
tr_ms.attach_response(inv_ms)

# remove response
# ... output unit should be m/s in both cases
# ... see https://docs.obspy.org/packages/autogen/obspy.core.trace.Trace.remove_response.html#obspy.core.trace.Trace.remove_response
# ... output = "VEL": velocity, output unit is meters/second
tr_nms.remove_response(output='VEL')
tr_ms.remove_response(output='VEL')
# but it isn't, nano- prefix was not taken into account
print('nm/s response:', tr_nms.data.max())
print(' m/s response:', tr_ms.data.max())
```

and output:

>Channel Response
>     From nm/s () to COUNTS ()
>     Overall Sensitivity: 1.84549 defined at 50.000 Hz
>	3 stages:
>		Stage 1: PolesZerosResponseStage from nm/s to V, gain: 1.1e-06
>		Stage 2: CoefficientsTypeResponseStage from V to COUNTS, gain: 1.6777e+06
>		Stage 3: FIRResponseStage from COUNTS to COUNTS, gain: 1

>Channel Response
>	From m/s () to COUNTS ()
>	Overall Sensitivity: 1.84549e+09 defined at 50.000 Hz
>	3 stages:
>		Stage 1: PolesZerosResponseStage from m/s to V, gain: 1100
>		Stage 2: CoefficientsTypeResponseStage from V to COUNTS, gain: 1.6777e+06
>		Stage 3: FIRResponseStage from COUNTS to COUNTS, gain: 1

>nm/s response: 9327.849844573393
> m/s response: 9.327849844573399e-06

OK, I see. Well, we definitely wanna do something about this. I guess, ideally we just want to use what is in place inside the evalresp C code, the unit juggling seems to be done there, not sure why it isn't applied inside evalresp. Anybody used to debugging C want to give it a shot?

Alternatively we could do it in Python, like mentioned and commented out in the code. But it feels like it would be safer to find out why it is not applied inside evalresp  try to fix that instead. 
So I had a more detailed look at this today. The way we call evalresp (the quite old version we use), the function that evaluates the scale factor and sets a global variable in evalresp to account for it later never gets called the way we use only a few select functions of libevalresp.

From what I can see evalresp also just looks at the first selected stage input units and sets that scale factor accordingly, so we can just do the same in Python for now.

N.b.: It looks like in newer versions of evalresp this was changed and the scale factor gets attached to the channel object.. ideally we'd want to update to a more recent version of evalresp, tbh, but that might be quite some work changing our wrapper code.

Resolving patch:
diff --git a/obspy/core/inventory/response.py b/obspy/core/inventory/response.py
--- a/obspy/core/inventory/response.py
+++ b/obspy/core/inventory/response.py
@@ -1139,10 +1139,6 @@ def _call_eval_resp_for_frequencies(
 
         frequencies = np.asarray(frequencies)
 
-        # Whacky. Evalresp uses a global variable and uses that to scale the
-        # response if it encounters any unit that is not SI.
-        scale_factor = [1.0]
-
         def get_unit_mapping(key):
             try:
                 key = key.upper()
@@ -1209,13 +1205,15 @@ def get_unit_mapping(key):
 
             # Scale factor with the same logic as evalresp.
             if key in ["CM/S**2", "CM/S", "CM/SEC", "CM"]:
-                scale_factor[0] = 1.0E2
+                scale_factor = 1.0E2
             elif key in ["MM/S**2", "MM/S", "MM/SEC", "MM"]:
-                scale_factor[0] = 1.0E3
+                scale_factor = 1.0E3
             elif key in ["NM/S**2", "NM/S", "NM/SEC", "NM"]:
-                scale_factor[0] = 1.0E9
+                scale_factor = 1.0E9
+            else:
+                scale_factor = 1.0
 
-            return value
+            return value, scale_factor
 
         all_stages = defaultdict(list)
 
@@ -1270,6 +1268,15 @@ def get_unit_mapping(key):
                         "units of stage 2."
                     warnings.warn(msg)
 
+        # determine the scale factor from the first stage input units
+        # Evalresp (in the old version we still use) uses a whacky global
+        # variable and uses that to scale the response if it encounters any
+        # unit that is not SI but the way we use the library, the functions
+        # that set that global variable never get called, so do the same, just
+        # outside of evalresp for now
+        _, scale_factor = get_unit_mapping(
+            all_stages[stage_list[0]][0].input_units)
+
         for stage_number in stage_list:
             st = ew.Stage()
             st.sequence_no = stage_number
@@ -1279,8 +1286,8 @@ def get_unit_mapping(key):
             blockette = all_stages[stage_number][0]
 
             # Write the input and output units.
-            st.input_units = get_unit_mapping(blockette.input_units)
-            st.output_units = get_unit_mapping(blockette.output_units)
+            st.input_units, _ = get_unit_mapping(blockette.input_units)
+            st.output_units, _ = get_unit_mapping(blockette.output_units)
 
             if isinstance(blockette, PolesZerosResponseStage):
                 blkt = ew.Blkt()
@@ -1603,8 +1610,10 @@ def get_unit_mapping(key):
                 e, m = ew.ENUM_ERROR_CODES[rc]
                 raise e('calc_resp: ' + m)
 
-            # XXX: Check if this is really not needed.
-            # output *= scale_factor[0]
+            # XXX: We currently need to do this outside of evalresp since the
+            # functions evaluating and setting the global scale factor variable
+            # currently never get called
+            output *= scale_factor
 
         finally:
             clibevresp.curr_file.value = None


Test patch:
diff --git a/obspy/core/tests/data/SL_BOJS_LHZ.xml b/obspy/core/tests/data/SL_BOJS_LHZ.xml
new file mode 100644
--- /dev/null
+++ b/obspy/core/tests/data/SL_BOJS_LHZ.xml
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><FDSNStationXML xmlns="http://www.fdsn.org/xml/station/1" schemaVersion="1.1"><Source>SeisComP</Source><Sender>ODC</Sender><Created>2024-03-12T12:06:23.035107</Created><Network code="SL" startDate="1970-01-01T00:00:00"><Description>Seismic Network of the Republic of Slovenia</Description><Station code="BOJS" startDate="2004-02-17T00:00:00"><Latitude>45.5043</Latitude><Longitude>15.2518</Longitude><Elevation>252</Elevation><Site><Name>Bojanci, SL</Name></Site><CreationDate>2004-02-17T00:00:00</CreationDate><Channel code="LHZ" startDate="2020-09-03T00:00:00" locationCode=""><Comment id="1"><Value>1001 6309</Value></Comment><Latitude>45.5043</Latitude><Longitude>15.2518</Longitude><Elevation>252</Elevation><Depth>0</Depth><Azimuth>0</Azimuth><Dip>-90</Dip><SampleRate>1</SampleRate><SampleRateRatio><NumberSamples>1</NumberSamples><NumberSeconds>1</NumberSeconds></SampleRateRatio><Sensor resourceId="Sensor/20210521090837.450331.488"><Type>V</Type><Description>Nanometrics Trillium 360 sec Response/Quanterra 33</Description><Model>V</Model></Sensor><DataLogger resourceId="Datalogger/20210521090837.450249.487"/><Response><InstrumentSensitivity><Value>1.84549</Value><Frequency>50</Frequency><InputUnits><Name>nm/s</Name></InputUnits><OutputUnits><Name>COUNTS</Name></OutputUnits></InstrumentSensitivity><Stage number="1"><PolesZeros resourceId="ResponsePAZ/20210521090837.449313.479" name="ResponsePAZ/20210521090837.449313.479"><InputUnits><Name>nm/s</Name></InputUnits><OutputUnits><Name>V</Name></OutputUnits><PzTransferFunctionType>LAPLACE (RADIANS/SECOND)</PzTransferFunctionType><NormalizationFactor>8.57196e+17</NormalizationFactor><NormalizationFrequency>0</NormalizationFrequency><Zero number="10"><Real>0</Real><Imaginary>0</Imaginary></Zero><Zero number="11"><Real>0</Real><Imaginary>0</Imaginary></Zero><Zero number="12"><Real>-160</Real><Imaginary>0</Imaginary></Zero><Zero number="13"><Real>-350</Real><Imaginary>0</Imaginary></Zero><Zero number="14"><Real>-3079</Real><Imaginary>0</Imaginary></Zero><Pole number="0"><Real>-0.01189</Real><Imaginary>-0.01189</Imaginary></Pole><Pole number="1"><Real>-0.01189</Real><Imaginary>0.01189</Imaginary></Pole><Pole number="2"><Real>-146</Real><Imaginary>0</Imaginary></Pole><Pole number="3"><Real>-360</Real><Imaginary>-405</Imaginary></Pole><Pole number="4"><Real>-360</Real><Imaginary>405</Imaginary></Pole><Pole number="5"><Real>-1234</Real><Imaginary>0</Imaginary></Pole><Pole number="6"><Real>-4900</Real><Imaginary>-5200</Imaginary></Pole><Pole number="7"><Real>-4900</Real><Imaginary>5200</Imaginary></Pole><Pole number="8"><Real>-7200</Real><Imaginary>-1700</Imaginary></Pole><Pole number="9"><Real>-7200</Real><Imaginary>1700</Imaginary></Pole></PolesZeros><Decimation><InputSampleRate>0</InputSampleRate><Factor>1</Factor><Offset>0</Offset><Delay>0</Delay><Correction>0</Correction></Decimation><StageGain><Value>1.1e-06</Value><Frequency>0.02</Frequency></StageGain></Stage><Stage number="2"><Coefficients><InputUnits><Name>V</Name></InputUnits><OutputUnits><Name>COUNTS</Name></OutputUnits><CfTransferFunctionType>DIGITAL</CfTransferFunctionType></Coefficients><Decimation><InputSampleRate>1</InputSampleRate><Factor>1</Factor><Offset>0</Offset><Delay>0</Delay><Correction>0</Correction></Decimation><StageGain><Value>1677700</Value><Frequency>0</Frequency></StageGain></Stage><Stage number="3"><FIR resourceId="ResponseFIR/20210521090837.449483.480" name="ResponseFIR/20210521090837.449483.480"><InputUnits><Name>COUNTS</Name></InputUnits><OutputUnits><Name>COUNTS</Name></OutputUnits><Symmetry>NONE</Symmetry><NumeratorCoefficient>-1.22004e-16</NumeratorCoefficient><NumeratorCoefficient>3.37278e-11</NumeratorCoefficient><NumeratorCoefficient>-3.19094e-07</NumeratorCoefficient><NumeratorCoefficient>1.26313e-06</NumeratorCoefficient><NumeratorCoefficient>-1.48491e-05</NumeratorCoefficient><NumeratorCoefficient>0.00332199</NumeratorCoefficient><NumeratorCoefficient>0.00108323</NumeratorCoefficient><NumeratorCoefficient>-0.00624046</NumeratorCoefficient><NumeratorCoefficient>0.0126566</NumeratorCoefficient><NumeratorCoefficient>-0.0157663</NumeratorCoefficient><NumeratorCoefficient>0.00956762</NumeratorCoefficient><NumeratorCoefficient>0.010711</NumeratorCoefficient><NumeratorCoefficient>-0.0456425</NumeratorCoefficient><NumeratorCoefficient>0.0897523</NumeratorCoefficient><NumeratorCoefficient>-0.132437</NumeratorCoefficient><NumeratorCoefficient>0.155289</NumeratorCoefficient><NumeratorCoefficient>0.812371</NumeratorCoefficient><NumeratorCoefficient>0.185415</NumeratorCoefficient><NumeratorCoefficient>-0.140609</NumeratorCoefficient><NumeratorCoefficient>0.0897083</NumeratorCoefficient><NumeratorCoefficient>-0.0424397</NumeratorCoefficient><NumeratorCoefficient>0.00714001</NumeratorCoefficient><NumeratorCoefficient>0.0121051</NumeratorCoefficient><NumeratorCoefficient>-0.0169307</NumeratorCoefficient><NumeratorCoefficient>0.0127735</NumeratorCoefficient><NumeratorCoefficient>-0.00586424</NumeratorCoefficient><NumeratorCoefficient>0.000645177</NumeratorCoefficient><NumeratorCoefficient>0.00348713</NumeratorCoefficient><NumeratorCoefficient>-8.53058e-05</NumeratorCoefficient><NumeratorCoefficient>2.29713e-06</NumeratorCoefficient><NumeratorCoefficient>-2.2583e-07</NumeratorCoefficient></FIR><Decimation><InputSampleRate>1</InputSampleRate><Factor>1</Factor><Offset>0</Offset><Delay>0</Delay><Correction>0</Correction></Decimation><StageGain><Value>1</Value><Frequency>0.02</Frequency></StageGain></Stage></Response></Channel></Station></Network></FDSNStationXML>
diff --git a/obspy/core/tests/test_response.py b/obspy/core/tests/test_response.py
--- a/obspy/core/tests/test_response.py
+++ b/obspy/core/tests/test_response.py
@@ -537,3 +537,64 @@ def test_unknown_units_no_integration(self, testdata):
             data = resp.get_evalresp_response_for_frequencies(freqs)
         assert resp.instrument_sensitivity.value == overall_sensitivity
         assert np.abs(data).tolist() == [overall_sensitivity] * len(freqs)
+
+    def test_non_SI_unit_input_first_stage(self, testdata):
+        """
+        Regression test for #3369
+
+        Test that non-SI units input (e.g. nanometer/second) is properly taken
+        into account when removing the instrument response, i.e. the additional
+        scaling factor (e.g. 1e09 for nm/s) is applied to the output.
+
+        The change in that issue affects
+        Response._call_eval_resp_for_frequencies() which in turn gets called
+        by..
+         - Response._get_overall_sensitivity_and_gain()
+         - Response.get_evalresp_response_for_frequencies()
+
+        Test all of those three for good measure.
+        """
+        inv = read_inventory(testdata['SL_BOJS_LHZ.xml'], "STATIONXML")
+
+        # original data has nm/s as a non-SI input unit. setup clones with some
+        # different input units
+        inv_nms = inv
+        inv_ms = inv_nms.copy()
+        inv_ms[0][0][0].response.instrument_sensitivity.input_units = 'm/s'
+        inv_ms[0][0][0].response.instrument_sensitivity.value *= 1e9
+        inv_ms[0][0][0].response.response_stages[0].input_units = 'm/s'
+        inv_ms[0][0][0].response.response_stages[0].stage_gain *= 1e9
+        inv_mms = inv_nms.copy()
+        inv_mms[0][0][0].response.instrument_sensitivity.input_units = 'mm/s'
+        inv_mms[0][0][0].response.instrument_sensitivity.value *= 1e6
+        inv_mms[0][0][0].response.response_stages[0].input_units = 'mm/s'
+        inv_mms[0][0][0].response.response_stages[0].stage_gain *= 1e6
+        inv_cms = inv_nms.copy()
+        inv_cms[0][0][0].response.instrument_sensitivity.input_units = 'cm/s'
+        inv_cms[0][0][0].response.instrument_sensitivity.value *= 1e7
+        inv_cms[0][0][0].response.response_stages[0].input_units = 'cm/s'
+        inv_cms[0][0][0].response.response_stages[0].stage_gain *= 1e7
+
+        # test at two frequencies in the flat response part
+        freqs = [0.01, 0.1]
+        expected = [1.84e+09, 1.85e+09]
+
+        for inv in (inv_nms, inv_ms, inv_mms, inv_cms):
+            resp = inv[0][0][0].response
+
+            # test _call_eval_resp_for_frequencies()
+            got, _ = resp._call_eval_resp_for_frequencies(
+                freqs, output='VEL', start_stage=None, end_stage=None)
+            got = np.abs(got)
+            np.testing.assert_allclose(got, expected, rtol=1e-2)
+
+            # test get_evalresp_response_for_frequencies()
+            got = resp.get_evalresp_response_for_frequencies(
+                freqs, output='VEL', start_stage=None, end_stage=None)
+            got = np.abs(got)
+            np.testing.assert_allclose(got, expected, rtol=1e-2)
+
+            # test _get_overall_sensitivity_and_gain()
+            got = [resp._get_overall_sensitivity_and_gain(f, output='VEL')[1]
+                   for f in freqs]
+            np.testing.assert_allclose(got, expected, rtol=1e-2)