Skip to content

Commit 45a11e5

Browse files
committed
Add total_loss parameter as alternative to loss_coef for fiber attenuation
Support OTDR-measured total span loss via new Fiber.total_loss parameter. When provided, it overrides loss_coef with an effective per-km value. - Add total_loss field to Fiber element with validation - Compute effective loss_coef = total_loss / length when total_loss is set - Add tests for total_loss functionality and edge cases - Add author entry to AUTHORS.rst
1 parent e0229c6 commit 45a11e5

6 files changed

Lines changed: 174 additions & 7 deletions

File tree

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ To learn how to contribute, please see CONTRIBUTING.md
1818
- Gabriele Galimberti (Cisco) <ggalimbe@cisco.com>
1919
- Gert Grammel (Juniper Networks) <ggrammel@juniper.net>
2020
- Giacomo Borraccini (NEC Laboratories America) <gborraccini@nec-labs.com>
21+
- Linqi Xiao (University of Texas at Dallas) <linqi.xiao@utdallas.edu>, <linqixiao2021@gmail.com>, <linqi.xiao@ieee.org>
2122
- Gilad Goldfarb (Facebook) <giladg@fb.com>
2223
- James Powell (Telecom Infra Project) <james.powell@telecominfraproject.com>
2324
- Jan Kundrát (Telecom Infra Project) <jkt@jankundrat.com>

gnpy/core/elements.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,14 @@ def to_json(self):
903903
'con_in': self.params.con_in,
904904
'con_out': self.params.con_out
905905
}
906-
if isinstance(self.params.loss_coef, ndarray):
906+
if self.params.total_loss is not None:
907+
if self.params.total_loss.size > 1:
908+
params["total_loss_per_frequency"] = [
909+
{"frequency": frequency, "total_loss_value": round(float(tl), 6)}
910+
for frequency, tl in zip(self.params.f_loss_ref, self.params.total_loss)]
911+
else:
912+
params["total_loss"] = round(float(self.params.total_loss), 6)
913+
elif isinstance(self.params.loss_coef, ndarray):
907914
params["loss_coef_per_frequency"] = [
908915
{"frequency": frequency, "loss_coef_value": round(loss * 1e3, 6)}
909916
for frequency, loss in zip(self.params.f_loss_ref, self.params.loss_coef)]

gnpy/core/parameters.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,32 @@ def __init__(self, **kwargs):
351351
self._pmd_coef_defined = kwargs.get('pmd_coef_defined', kwargs['pmd_coef'] is True)
352352

353353
# Loss Coefficient
354-
if isinstance(kwargs['loss_coef'], dict):
355-
self._loss_coef = asarray(kwargs['loss_coef']['value']) * 1e-3 # lineic loss dB/m
356-
self._f_loss_ref = asarray(kwargs['loss_coef']['frequency']) # Hz
354+
# Support total_loss (dB) as an alternative to loss_coef (dB/km).
355+
# When total_loss is provided, it represents the total fiber attenuation
356+
# (excluding connectors), as typically measured by OTDR.
357+
# The effective loss_coef is then derived as total_loss / length.
358+
self._total_loss = None
359+
if 'total_loss' in kwargs and kwargs['total_loss'] is not None:
360+
total_loss = kwargs['total_loss']
361+
if isinstance(total_loss, dict):
362+
# Frequency-dependent total loss: {value: [...], frequency: [...]}
363+
self._total_loss = asarray(total_loss['value']) # dB
364+
self._f_loss_ref = asarray(total_loss['frequency']) # Hz
365+
self._loss_coef = self._total_loss / (self._length * 1e-3) # dB/km -> dB/m (*1e-3)
366+
self._loss_coef = self._loss_coef * 1e-3 # dB/m
367+
else:
368+
self._total_loss = asarray(float(total_loss)) # dB
369+
self._f_loss_ref = asarray(self._ref_frequency) # Hz
370+
self._loss_coef = asarray(self._total_loss / (self._length * 1e-3)) * 1e-3 # dB/m
371+
elif 'loss_coef' in kwargs:
372+
if isinstance(kwargs['loss_coef'], dict):
373+
self._loss_coef = asarray(kwargs['loss_coef']['value']) * 1e-3 # lineic loss dB/m
374+
self._f_loss_ref = asarray(kwargs['loss_coef']['frequency']) # Hz
375+
else:
376+
self._loss_coef = asarray(kwargs['loss_coef']) * 1e-3 # lineic loss dB/m
377+
self._f_loss_ref = asarray(self._ref_frequency) # Hz
357378
else:
358-
self._loss_coef = asarray(kwargs['loss_coef']) * 1e-3 # lineic loss dB/m
359-
self._f_loss_ref = asarray(self._ref_frequency) # Hz
379+
raise KeyError('loss_coef')
360380
# Lumped Losses
361381
self._lumped_losses = kwargs['lumped_losses'] if 'lumped_losses' in kwargs else array([])
362382
self._latency = self._length / (c / self._n1) # s
@@ -446,6 +466,10 @@ def ref_wavelength(self):
446466
def ref_frequency(self):
447467
return self._ref_frequency
448468

469+
@property
470+
def total_loss(self):
471+
return self._total_loss
472+
449473
@property
450474
def loss_coef(self):
451475
return self._loss_coef
@@ -464,7 +488,15 @@ def latency(self):
464488

465489
def asdict(self):
466490
dictionary = super().asdict()
467-
dictionary['loss_coef'] = self.loss_coef * 1e3
491+
if self._total_loss is not None:
492+
if self._total_loss.size > 1:
493+
dictionary['total_loss'] = {'value': self._total_loss.tolist(),
494+
'frequency': self._f_loss_ref.tolist()}
495+
else:
496+
dictionary['total_loss'] = float(self._total_loss)
497+
dictionary.pop('loss_coef', None)
498+
else:
499+
dictionary['loss_coef'] = self.loss_coef * 1e3
468500
dictionary['length_units'] = 'm'
469501
if len(self.lumped_losses) == 0:
470502
dictionary.pop('lumped_losses')

gnpy/tools/convert_legacy_yang.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
convert_design_band, convert_back_design_band, \
2626
convert_none_to_empty, convert_empty_to_none, \
2727
convert_loss_coeff_list, convert_back_loss_coeff_list, \
28+
convert_total_loss_list, convert_back_total_loss_list, \
2829
ELEMENTS_KEY, PATH_REQUEST_KEY, RESPONSE_KEY, SPECTRUM_KEY, EQPT_TYPES, EDFA_CONFIG_KEYS, SIM_PARAMS_KEYS, \
2930
TOPO_NMSP, SERV_NMSP, EQPT_NMSP, SPECTRUM_NMSP, SIM_PARAMS_NMSP, EDFA_CONFIG_NMSP, RESP_NMSP, \
3031
dump_data, add_missing_default_type_variety, \
@@ -56,13 +57,15 @@ def legacy_to_yang(json_data: Dict) -> Dict:
5657
json_data = convert_degree(json_data)
5758
json_data = convert_design_band(json_data)
5859
json_data = convert_loss_coeff_list(json_data)
60+
json_data = convert_total_loss_list(json_data)
5961
json_data = convert_raman_coef(json_data)
6062
json_data = {TOPO_NMSP: json_data}
6163
elif TOPO_NMSP in json_data:
6264
# then this is a new format topology json, ensure that there are no issues
6365
json_data[TOPO_NMSP] = convert_degree(json_data[TOPO_NMSP])
6466
json_data[TOPO_NMSP] = convert_design_band(json_data[TOPO_NMSP])
6567
json_data[TOPO_NMSP] = convert_loss_coeff_list(json_data[TOPO_NMSP])
68+
json_data[TOPO_NMSP] = convert_total_loss_list(json_data[TOPO_NMSP])
6669
json_data[TOPO_NMSP] = remove_null_region_city(json_data[TOPO_NMSP])
6770

6871
# case of equipment json
@@ -149,11 +152,13 @@ def yang_to_legacy(json_data: Dict) -> Dict:
149152
json_data = convert_back_degree(json_data)
150153
json_data = convert_back_design_band(json_data)
151154
json_data = convert_back_loss_coeff_list(json_data)
155+
json_data = convert_back_total_loss_list(json_data)
152156
json_data = convert_back_raman_coef(json_data)
153157
elif TOPO_NMSP in json_data:
154158
json_data = convert_back_degree(json_data[TOPO_NMSP])
155159
json_data = convert_back_design_band(json_data)
156160
json_data = convert_back_loss_coeff_list(json_data)
161+
json_data = convert_back_total_loss_list(json_data)
157162
json_data = convert_back_raman_coef(json_data)
158163

159164
# case of equipment json

gnpy/tools/yang_convert_utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
SPECTRUM_KEY = 'spectrum'
3535
LOSS_COEF_KEY = 'loss_coef'
3636
LOSS_COEF_KEY_PER_FREQ = 'loss_coef_per_frequency'
37+
TOTAL_LOSS_KEY = 'total_loss'
38+
TOTAL_LOSS_KEY_PER_FREQ = 'total_loss_per_frequency'
3739
RAMAN_COEF_KEY = 'raman_coefficient'
3840
RAMAN_EFFICIENCY_KEY = 'raman_efficiency'
3941
EQPT_TYPES = ['Edfa', 'Transceiver', 'Fiber', 'Roadm']
@@ -399,6 +401,46 @@ def convert_back_loss_coeff_list(json_data: Dict) -> Dict:
399401
return json_data
400402

401403

404+
def convert_total_loss_list(json_data: Dict) -> Dict:
405+
"""Convert total_loss per-frequency dict format to YANG list format.
406+
407+
:param json_data: The input JSON topology data to convert.
408+
:type json_data: Dict
409+
:return: the converted JSON data
410+
:rtype: Dict
411+
"""
412+
for elem in json_data[ELEMENTS_KEY]:
413+
if PARAMS_KEY in elem and TOTAL_LOSS_KEY in elem[PARAMS_KEY] \
414+
and isinstance(elem[PARAMS_KEY][TOTAL_LOSS_KEY], dict):
415+
total_loss_per_frequency = elem[PARAMS_KEY].pop(TOTAL_LOSS_KEY)
416+
total_loss_list = total_loss_per_frequency.pop('value', None)
417+
frequency_list = total_loss_per_frequency.pop('frequency', None)
418+
if total_loss_list:
419+
new_total_loss_per_frequency = [{'frequency': f, 'total_loss_value': v}
420+
for f, v in zip(frequency_list, total_loss_list)]
421+
elem[PARAMS_KEY][TOTAL_LOSS_KEY_PER_FREQ] = new_total_loss_per_frequency
422+
return json_data
423+
424+
425+
def convert_back_total_loss_list(json_data: Dict) -> Dict:
426+
"""Convert YANG total_loss list format back to dict format.
427+
428+
:param json_data: The input JSON topology data to convert back
429+
:type json_data: Dict
430+
:return: the converted JSON data
431+
:rtype: Dict
432+
"""
433+
for elem in json_data[ELEMENTS_KEY]:
434+
if PARAMS_KEY in elem and TOTAL_LOSS_KEY_PER_FREQ in elem[PARAMS_KEY]:
435+
total_loss_per_frequency = elem[PARAMS_KEY].pop(TOTAL_LOSS_KEY_PER_FREQ)
436+
if total_loss_per_frequency:
437+
new_total_loss_per_frequency = {
438+
'frequency': [item['frequency'] for item in total_loss_per_frequency],
439+
'value': [item['total_loss_value'] for item in total_loss_per_frequency]}
440+
elem[PARAMS_KEY][TOTAL_LOSS_KEY] = new_total_loss_per_frequency
441+
return json_data
442+
443+
402444
def convert_design_band(json_data: Dict) -> Dict:
403445
"""Convert legacy json topology format to gnpy yang format revision 2025-01-20:
404446

tests/test_parameters.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,83 @@ def test_fiber_parameters():
6161
norm_gamma_raman_default_g0_no_ssmf = no_ssmf_fiber_params.raman_coefficient.normalized_gamma_raman
6262

6363
assert_allclose(norm_gamma_raman_explicit_g0, norm_gamma_raman_default_g0_no_ssmf, rtol=1e-10)
64+
65+
66+
def test_fiber_total_loss_scalar():
67+
"""Test that total_loss parameter correctly computes loss_coef from total_loss / length."""
68+
fiber_dict = {
69+
'length': 80,
70+
'length_units': 'km',
71+
'att_in': 0,
72+
'con_in': 0.5,
73+
'con_out': 0.5,
74+
'dispersion': 1.67e-05,
75+
'effective_area': 8.3e-11,
76+
'pmd_coef': 1.265e-15,
77+
'total_loss': 16.0, # 16 dB total loss for 80 km → 0.2 dB/km
78+
}
79+
params = FiberParams(**fiber_dict)
80+
# total_loss = 16 dB, length = 80 km → loss_coef should be 0.2 dB/km = 0.0002 dB/m
81+
assert_allclose(params.loss_coef, 0.0002, rtol=1e-10)
82+
assert_allclose(params.total_loss, 16.0, rtol=1e-10)
83+
84+
85+
def test_fiber_total_loss_per_frequency():
86+
"""Test that frequency-dependent total_loss correctly computes loss_coef per frequency."""
87+
fiber_dict = {
88+
'length': 80,
89+
'length_units': 'km',
90+
'att_in': 0,
91+
'con_in': 0.5,
92+
'con_out': 0.5,
93+
'dispersion': 1.67e-05,
94+
'effective_area': 8.3e-11,
95+
'pmd_coef': 1.265e-15,
96+
'total_loss': {
97+
'value': [16.0, 17.6], # dB at two frequencies
98+
'frequency': [191.35e12, 196.1e12],
99+
},
100+
}
101+
params = FiberParams(**fiber_dict)
102+
# 16.0 / 80 = 0.2 dB/km = 0.0002 dB/m
103+
# 17.6 / 80 = 0.22 dB/km = 0.00022 dB/m
104+
assert_allclose(params.loss_coef, [0.0002, 0.00022], rtol=1e-10)
105+
assert_allclose(params.total_loss, [16.0, 17.6], rtol=1e-10)
106+
107+
108+
def test_fiber_total_loss_backward_compat():
109+
"""Ensure loss_coef still works when total_loss is not provided."""
110+
fiber_dict = {
111+
'length': 80,
112+
'length_units': 'km',
113+
'att_in': 0,
114+
'con_in': 0.5,
115+
'con_out': 0.5,
116+
'dispersion': 1.67e-05,
117+
'effective_area': 8.3e-11,
118+
'pmd_coef': 1.265e-15,
119+
'loss_coef': 0.2, # dB/km
120+
}
121+
params = FiberParams(**fiber_dict)
122+
assert_allclose(params.loss_coef, 0.0002, rtol=1e-10)
123+
assert params.total_loss is None
124+
125+
126+
def test_fiber_total_loss_asdict():
127+
"""Test that asdict preserves total_loss when it was the original input."""
128+
fiber_dict = {
129+
'length': 80,
130+
'length_units': 'km',
131+
'att_in': 0,
132+
'con_in': 0.5,
133+
'con_out': 0.5,
134+
'dispersion': 1.67e-05,
135+
'effective_area': 8.3e-11,
136+
'pmd_coef': 1.265e-15,
137+
'total_loss': 16.0,
138+
}
139+
params = FiberParams(**fiber_dict)
140+
d = params.asdict()
141+
assert 'total_loss' in d
142+
assert 'loss_coef' not in d
143+
assert_allclose(d['total_loss'], 16.0, rtol=1e-10)

0 commit comments

Comments
 (0)