Skip to content

Commit 9ec9758

Browse files
sstruziksamblesawsbuild
authored
Feature/v4 integration (#173)
* add class of business detection * Point package builder to OED4 pre-release spec file * Set package to version 4.0.0 * Fix default for do_disaggregation to true (#169) * OED v4 release (#170) * branches renamed in OED repo (#172) * test oed_v4 * pep8 * ep8 * fixup test * pep8 --------- Co-authored-by: Sam Gamble <hexadessa@gmail.com> Co-authored-by: awsbuild <awsbuild@oasislmf.org> Co-authored-by: sambles <sambles@users.noreply.github.com>
1 parent 3c429e9 commit 9ec9758

9 files changed

+784
-28
lines changed

ods_tools/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = '3.2.9'
1+
__version__ = '4.0.0'
22

33
import logging
44

ods_tools/data/model_settings_schema.json

Lines changed: 588 additions & 2 deletions
Large diffs are not rendered by default.

ods_tools/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def check(**kwargs):
4646
"""run the check command on Exposure"""
4747
logger = logging.getLogger(__name__)
4848
args_set = {k for k, v in kwargs.items() if v is not None}
49-
args_exp = set(['location', 'account', 'ri_info', 'ri_scope'])
49+
args_exp = set(['location', 'account', 'ri_info', 'ri_scope', 'oed_dir'])
5050

5151
try:
5252
if args_exp.intersection(set(args_set)):

ods_tools/oed/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .common import (
77
OdsException, PANDAS_COMPRESSION_MAP, PANDAS_DEFAULT_NULL_VALUES, USUAL_FILE_NAME, OED_TYPE_TO_NAME,
88
OED_NAME_TO_TYPE, OED_IDENTIFIER_FIELDS, VALIDATOR_ON_ERROR_ACTION, DEFAULT_VALIDATION_CONFIG, OED_PERIL_COLUMNS, fill_empty,
9-
UnknownColumnSaveOption, BLANK_VALUES, is_empty
9+
UnknownColumnSaveOption, BLANK_VALUES, is_empty, ClassOfBusiness
1010
)
1111

1212

@@ -15,5 +15,5 @@
1515
'AnalysisSettingHandler', 'ModelSettingHandler', 'ModelSettingSchema', 'AnalysisSettingSchema',
1616
'OdsException', 'PANDAS_COMPRESSION_MAP', 'PANDAS_DEFAULT_NULL_VALUES', 'USUAL_FILE_NAME', 'OED_TYPE_TO_NAME',
1717
'OED_NAME_TO_TYPE', 'OED_IDENTIFIER_FIELDS', 'VALIDATOR_ON_ERROR_ACTION', 'DEFAULT_VALIDATION_CONFIG', 'OED_PERIL_COLUMNS', 'fill_empty',
18-
'UnknownColumnSaveOption', 'BLANK_VALUES', 'is_empty',
18+
'UnknownColumnSaveOption', 'BLANK_VALUES', 'is_empty', 'ClassOfBusiness'
1919
] # this is necessary for flake8 to pass, otherwise you get an unused import error

ods_tools/oed/common.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""
22
common static variable and ods_tools exceptions
33
"""
4+
import enum
5+
46
from urllib.parse import urlparse
57
from pathlib import Path
68
import numpy as np
@@ -125,9 +127,60 @@ def __get__(self, obj, type=None):
125127
'ReinsScope': ['ReinsNumber', 'PortNumber', 'AccNumber', 'LocNumber']
126128
}
127129

130+
131+
class ClassOfBusiness(enum.Enum):
132+
prop = 'PROP'
133+
mar = 'MAR'
134+
cyb = 'CYB'
135+
liabs = 'LIABS'
136+
137+
138+
CLASS_OF_BUSINESSES = {
139+
ClassOfBusiness.prop: {
140+
'name': 'Property',
141+
'field_status_name': 'Property field status',
142+
'subject_at_risk_source': 'location',
143+
'subject_at_risk_id_fields': ['PortNumber', 'AccNumber', 'LocNumber'],
144+
'coherence_rules': [
145+
{"name": "location", "type": "R", "r_sources": ["location"], },
146+
{"name": "reinsurance", "type": "CR", "c_sources": ["ri_info", "ri_scope"], "r_sources": ["account"]}
147+
],
148+
},
149+
ClassOfBusiness.mar: {
150+
'name': 'Marine Cargo',
151+
'field_status_name': 'Marine Cargo field status',
152+
'subject_at_risk_source': 'location',
153+
'subject_at_risk_id_fields': ['PortNumber', 'AccNumber', 'LocNumber'],
154+
'coherence_rules': [
155+
{"name": "location", "type": "R", "r_sources": ["location"], },
156+
{"name": "reinsurance", "type": "CR", "c_sources": ["ri_info", "ri_scope"], "r_sources": ["account"]}
157+
],
158+
},
159+
ClassOfBusiness.cyb: {
160+
'name': 'Cyber',
161+
'field_status_name': 'Cyber field status',
162+
'subject_at_risk_source': 'account',
163+
'subject_at_risk_id_fields': ['PortNumber', 'AccNumber'],
164+
'coherence_rules': [
165+
{"name": "account", "type": "R", "r_sources": ["account"]},
166+
{"name": "reinsurance", "type": "CR", "c_sources": ["ri_info", "ri_scope"]}
167+
]
168+
},
169+
ClassOfBusiness.liabs: {
170+
'name': 'Liability',
171+
'field_status_name': 'Liability field status',
172+
'subject_at_risk_source': 'account',
173+
'subject_at_risk_id_fields': ['PortNumber', 'AccNumber'],
174+
'coherence_rules': [
175+
{"name": "account", "type": "R", "r_sources": ["account"]},
176+
{"name": "reinsurance", "type": "CR", "c_sources": ["ri_info", "ri_scope"]}
177+
]
178+
},
179+
}
180+
128181
VALIDATOR_ON_ERROR_ACTION = {'raise', 'log', 'ignore', 'return'}
129182
DEFAULT_VALIDATION_CONFIG = [
130-
{'name': 'source_coherence', 'on_error': 'raise'},
183+
{'name': 'source_coherence', 'on_error': 'log'},
131184
{'name': 'required_fields', 'on_error': 'raise'},
132185
{'name': 'unknown_column', 'on_error': 'raise'},
133186
{'name': 'valid_values', 'on_error': 'raise'},

ods_tools/oed/exposure.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414

1515
from .common import (PANDAS_COMPRESSION_MAP,
1616
USUAL_FILE_NAME, OED_TYPE_TO_NAME,
17-
UnknownColumnSaveOption)
17+
UnknownColumnSaveOption, CLASS_OF_BUSINESSES, OdsException,
18+
ClassOfBusiness)
1819
from .oed_schema import OedSchema
1920
from .source import OedSource
2021
from .validator import Validator
@@ -30,14 +31,15 @@ class OedExposure:
3031
"""
3132
DEFAULT_EXPOSURE_CONFIG_NAME = 'exposure_info.json'
3233

33-
def __init__(self,
34+
def __init__(self, *,
3435
location=None,
3536
account=None,
3637
ri_info=None,
3738
ri_scope=None,
3839
oed_schema_info=None,
3940
currency_conversion=None,
4041
reporting_currency=None,
42+
class_of_business=None,
4143
check_oed=False,
4244
use_field=False,
4345
validation_config=None,
@@ -129,6 +131,8 @@ def fn(df):
129131

130132
self.reporting_currency = reporting_currency
131133

134+
self.class_of_business = class_of_business
135+
132136
self.validation_config = validation_config
133137

134138
if not working_dir:
@@ -139,6 +143,49 @@ def fn(df):
139143
if check_oed:
140144
self.check()
141145

146+
def get_class_of_business(self):
147+
any_field_info = next(iter(self.get_input_fields('null').values()))
148+
if 'Required Field' in any_field_info:
149+
logger.debug(f"OED schema version < 4.0.0, only support {ClassOfBusiness.prop}")
150+
return ClassOfBusiness.prop
151+
class_of_businesses = set(CLASS_OF_BUSINESSES)
152+
exclusion_messages = {}
153+
154+
for oed_source in self.get_oed_sources():
155+
present_field = set(field_info['Input Field Name'] for field_info in oed_source.get_column_to_field().values())
156+
for field_info in self.get_input_fields(oed_source.oed_type).values():
157+
for class_of_business in class_of_businesses:
158+
cob_field_status = CLASS_OF_BUSINESSES[class_of_business]['field_status_name']
159+
if field_info.get(cob_field_status) == 'R':
160+
if field_info['Input Field Name'] not in present_field:
161+
exclusion_messages.setdefault(class_of_business, {}).setdefault('missing', []).append(field_info['Input Field Name'])
162+
elif field_info.get(cob_field_status) == 'n/a':
163+
if field_info['Input Field Name'] in present_field:
164+
exclusion_messages.setdefault(class_of_business, {}).setdefault('present', []).append(field_info['Input Field Name'])
165+
166+
final_cobs = class_of_businesses.difference(exclusion_messages)
167+
if len(final_cobs) == 1:
168+
final_cobs = final_cobs.pop()
169+
logger.info(f"detected class of business is {final_cobs}")
170+
return final_cobs
171+
elif len(final_cobs) == 0:
172+
error_msg = "\n".join(f"{class_of_business}:"
173+
+ ("\n " + ", ".join(messages['missing']) + " missing" if messages.get('missing') else "")
174+
+ ("\n " + ", ".join(messages['present']) + " present" if messages.get('present') else "")
175+
for class_of_business, messages in exclusion_messages.items())
176+
raise OdsException(error_msg)
177+
elif len(final_cobs) == 2 and len(final_cobs.difference({ClassOfBusiness.prop, ClassOfBusiness.mar})) == 0:
178+
# Marine and Property have mostly the same column, default to Property if undetermined
179+
return ClassOfBusiness.prop
180+
else:
181+
raise OdsException(f"could not determine the COB of the exposure between those {final_cobs}")
182+
183+
@property
184+
def class_of_business_info(self):
185+
if self.class_of_business is None:
186+
self.class_of_business = self.get_class_of_business()
187+
return CLASS_OF_BUSINESSES[self.class_of_business]
188+
142189
@classmethod
143190
def resolve_oed_info(cls, oed_info, df_engine):
144191
if isinstance(oed_info, (str, Path)):
@@ -208,7 +255,7 @@ def find_fp(names):
208255
kwargs['working_dir'] = oed_dir
209256

210257
missing_files = [file for file, found in files_found.items() if not found]
211-
if missing_files:
258+
if missing_files and False:
212259
raise FileNotFoundError(f"Files not found in current path ({oed_dir}): {', '.join(missing_files)}")
213260

214261
return cls(**{**config, **kwargs})
@@ -256,6 +303,11 @@ def get_oed_sources(self):
256303
if oed_source:
257304
yield oed_source
258305

306+
def get_subject_at_risk_source(self) -> OedSource:
307+
if self.class_of_business is None:
308+
self.class_of_business = self.get_class_of_business()
309+
return getattr(self, CLASS_OF_BUSINESSES[self.class_of_business]['subject_at_risk_source'])
310+
259311
def save_config(self, filepath):
260312
"""
261313
save data to directory, loadable later on
@@ -327,6 +379,9 @@ def check(self, validation_config=None):
327379
OdsException if some invalid data is found
328380
329381
"""
382+
if self.class_of_business is None:
383+
self.class_of_business = self.get_class_of_business()
384+
330385
if validation_config is None:
331386
validation_config = self.validation_config
332387
validator = Validator(self)

ods_tools/oed/oed_schema.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import json
2+
import logging
23
import os
34
from pathlib import Path
45
import numba as nb
56
import numpy as np
67

78
from .common import OdsException, BLANK_VALUES, cached_property, dtype_to_python
89

10+
logger = logging.getLogger(__name__)
11+
912
ENV_ODS_SCHEMA_PATH = os.getenv('ODS_SCHEMA_PATH')
1013

1114

@@ -74,6 +77,7 @@ def from_oed_schema_info(cls, oed_schema_info):
7477
elif isinstance(oed_schema_info, cls):
7578
return oed_schema_info
7679
elif oed_schema_info is None:
80+
logger.debug(f"loading default schema {cls.DEFAULT_ODS_SCHEMA_PATH}")
7781
return cls.from_json(cls.DEFAULT_ODS_SCHEMA_PATH)
7882
else:
7983
raise OdsException(f"{oed_schema_info} is not a supported format to create {cls} object")
@@ -245,7 +249,6 @@ def is_valid_value(value, valid_ranges, allow_blanks):
245249
Returns:
246250
True if value is in one of the range
247251
"""
248-
249252
if value in BLANK_VALUES:
250253
return allow_blanks
251254
for valid_range in valid_ranges:

ods_tools/oed/validator.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77
from collections.abc import Iterable
88

9-
from .common import (OdsException, OED_PERIL_COLUMNS, OED_IDENTIFIER_FIELDS, DEFAULT_VALIDATION_CONFIG,
9+
from .common import (OdsException, OED_PERIL_COLUMNS, OED_IDENTIFIER_FIELDS, DEFAULT_VALIDATION_CONFIG, CLASS_OF_BUSINESSES,
1010
VALIDATOR_ON_ERROR_ACTION, BLANK_VALUES, is_empty)
1111
from .oed_schema import OedSchema
1212

@@ -92,19 +92,24 @@ def invalid_data_to_str(_data):
9292
def check_source_coherence(self):
9393
""""""
9494
invalid_data = []
95-
if not self.exposure.location:
96-
invalid_data.append({'name': 'location', 'source': None,
97-
'msg': f"Exposure needs a Location file, location={self.exposure.location}"})
98-
99-
if self.exposure.ri_info or self.exposure.ri_scope:
100-
if not self.exposure.account:
101-
invalid_data.append({'name': 'account', 'source': None,
102-
'msg': f"Exposure needs account if reinsurance is provided account={self.exposure.account}"})
103-
104-
if not self.exposure.ri_info and self.exposure.ri_scope:
105-
invalid_data.append({'name': 'reinsurance', 'source': None,
106-
'msg': f"Exposure needs both ri_scope and ri_scope for reinsurance"
107-
f"ri_info={self.exposure.ri_info} ri_scope={self.exposure.ri_scope}"})
95+
coherence_rules = CLASS_OF_BUSINESSES[self.exposure.class_of_business]['coherence_rules']
96+
for coherence_rule in coherence_rules:
97+
r_sources = []
98+
if coherence_rule["type"] == "CR":
99+
c_sources = [getattr(self.exposure, source) for source in coherence_rule["c_sources"]]
100+
if any(c_sources):
101+
if not all(c_sources):
102+
invalid_data.append(
103+
{'name': coherence_rule['name'], 'source': None,
104+
'msg': f"Exposure needs all {coherence_rule['c_sources']} for {coherence_rule['name']}"
105+
f" got {c_sources}"})
106+
r_sources = [getattr(self.exposure, source) for source in coherence_rule.get("r_sources", [])]
107+
elif coherence_rule["type"] == "R":
108+
r_sources = [getattr(self.exposure, source) for source in coherence_rule["r_sources"]]
109+
110+
if not all(r_sources):
111+
invalid_data.append({'name': coherence_rule['name'], 'source': None,
112+
'msg': f"Exposure needs {coherence_rule['r_sources']}, got={r_sources}"})
108113

109114
return invalid_data
110115

@@ -126,8 +131,9 @@ def check_required_fields(self):
126131

127132
for field_info in input_fields.values():
128133
if field_info['Input Field Name'] not in field_to_columns:
129-
# OED v4 = 'Property field status' and OED v3 = 'Required Field'
130-
requ_field_ref = 'Property field status' if 'Property field status' in field_info else 'Required Field'
134+
requ_field_ref = CLASS_OF_BUSINESSES[self.exposure.class_of_business]['field_status_name']
135+
if requ_field_ref not in field_info: # OED v3 only support PROP and used 'Required Field'
136+
requ_field_ref = 'Required Field'
131137
if field_info.get(requ_field_ref) == 'R':
132138
invalid_data.append({'name': oed_source.oed_name, 'source': oed_source.current_source,
133139
'msg': f"missing required column {field_info['Input Field Name']}"})

tests/test_ods_package.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
sys.path.append(sys.path.pop(0))
2020

2121
from ods_tools.main import convert
22-
from ods_tools.oed import OedExposure, OedSchema, OdsException, ModelSettingSchema, AnalysisSettingSchema, OED_TYPE_TO_NAME, UnknownColumnSaveOption
22+
from ods_tools.oed import (OedExposure, OedSchema, OdsException, ModelSettingSchema, AnalysisSettingSchema, OED_TYPE_TO_NAME, UnknownColumnSaveOption,
23+
ClassOfBusiness)
2324
from ods_tools.odtf.controller import transform_format
2425

2526
logger = logging.getLogger(__file__)
@@ -115,6 +116,58 @@ def test_load_oed_from_config(self):
115116
exposure2 = OedExposure(**config)
116117
self.assertTrue(exposure.location.dataframe.equals(exposure2.location.dataframe))
117118

119+
def test_oed_V3(self):
120+
with tempfile.TemporaryDirectory() as tmp_run_dir:
121+
with open(os.path.join(tmp_run_dir, 'OpenExposureData_Spec.json'), 'wb') as schema_file:
122+
schema_file.write(
123+
urllib.request.urlopen('https://github.com/OasisLMF/ODS_OpenExposureData/releases/download/3.4.0/OpenExposureData_Spec.json')
124+
.read())
125+
126+
config = {
127+
'location': base_url + '/SourceLocOEDPiWind.csv',
128+
'account': base_url + '/SourceAccOEDPiWind.csv',
129+
'ri_info': base_url + '/SourceReinsInfoOEDPiWind.csv',
130+
'ri_scope': base_url + '/SourceReinsScopeOEDPiWind.csv',
131+
'oed_schema_info': os.path.join(tmp_run_dir, 'OpenExposureData_Spec.json'),
132+
'check_oed': True,
133+
'use_field': True,
134+
}
135+
assert OedExposure(**config).class_of_business == ClassOfBusiness.prop
136+
137+
def test_oed_cyber_example(self):
138+
oed_example_url = "https://raw.githubusercontent.com/OasisLMF/ODS_OpenExposureData/refs/heads/main/Examples"
139+
config = {
140+
'account': oed_example_url + '/cyber_account.csv',
141+
'check_oed': True, # issue with current marine exemple set to true and remove the correction when fixed
142+
'use_field': True,
143+
}
144+
assert OedExposure(**config).class_of_business == ClassOfBusiness.cyb
145+
146+
def test_oed_marinecargo_example(self):
147+
oed_example_url = "https://raw.githubusercontent.com/OasisLMF/ODS_OpenExposureData/refs/heads/main/Examples"
148+
config = {
149+
'location': oed_example_url + '/marinecargo_location.csv',
150+
'account': oed_example_url + '/marinecargo_account.csv',
151+
'check_oed': False, # issue with current marine exemple set to true and remove the correction when fixed
152+
'use_field': True,
153+
}
154+
## marine example manual fixup ###
155+
exposure = OedExposure(**config)
156+
exposure.account.dataframe["PolDedType6All"] = 1
157+
config['account'] = exposure.account.dataframe
158+
config['check_oed'] = True
159+
#####
160+
assert OedExposure(**config).class_of_business in [ClassOfBusiness.prop, ClassOfBusiness.mar]
161+
162+
def test_oed_liability_example(self):
163+
oed_example_url = "https://raw.githubusercontent.com/OasisLMF/ODS_OpenExposureData/refs/heads/main/Examples"
164+
config = {
165+
'account': oed_example_url + '/liability_account.csv',
166+
'check_oed': True,
167+
'use_field': True,
168+
}
169+
assert OedExposure(**config).class_of_business == ClassOfBusiness.liabs
170+
118171
def test_categorical_with_default(self):
119172
# UseReinsDates is a string column with a non null default, check default setting works
120173
with tempfile.TemporaryDirectory() as tmp_run_dir:

0 commit comments

Comments
 (0)