hln_ice 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +28 -3
- data/hln_ice.gemspec +43 -0
- data/lib/hln_ice/evaluations_parser.rb +14 -11
- data/lib/hln_ice/response_parser.rb +95 -102
- data/lib/hln_ice/series_parser.rb +106 -0
- data/lib/hln_ice/support.rb +19 -0
- data/lib/hln_ice/version.rb +1 -1
- data/lib/hln_ice.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 963f6531ca9e78e0f66d8ede2c7b7e34cc57676726ff7fe8069ceeec03a5b304
|
|
4
|
+
data.tar.gz: b164c7a89a955be98cca09d1f4d685166702549bcfec82e1c8e0e6502c359206
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87e3e121398de525f6649c3852694f6a64dbc7a6e49c5cc16e3f88ec7e060686b29b68b8e1bd8083fa05cb35874be8412531dd06411b5620522ca3b40b5aafff
|
|
7
|
+
data.tar.gz: c9b81008070ad31a93db22e27805db8d355e8f066fb7a45d25e5d778f75385cbe54f78dd6c2baed8f0623ad35a91a8c95dc8c52ddd838e38b69071c6a99c691b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-10-13
|
|
4
|
+
|
|
5
|
+
- **Fix recommendations getting the wrong status when the ICE server outputs series information** (`ICE_OUTPUTSERIESINFORMATION`). The parser took the first coded observation anywhere in a proposal, which could be the selected series instead of the status. When it was, the recommendation's `status` was a series code (e.g. `HEP_B_ADULT_3_DOSE_SERIES`) and its `reasons` were empty, so `simplified_status` reported that vaccine group as `"overdue"` whatever ICE recommended. ICE orders these blocks differently from one request to the next, so which vaccine groups were affected varied. Upgrade before turning series output on.
|
|
6
|
+
- Read every value from the observation it belongs to, by its `observationFocus` code system and code, instead of by position or with descendant searches. This applies to recommendation status and reasons, schedule authorities, dose evaluation status, and the patient and proposal elements. Responses from servers without series output parse exactly as before.
|
|
7
|
+
- Expose the series ICE selected for each recommendation under `series` (`code`, `name`, and `selection`: `"unambiguous"`, `"best_guess"`, or `"alternative"`), its `season` for seasonal vaccines (`code`, `name`, `starts_on`, and `ends_on`), and `doses_remaining` when the server has `ICE_OUTPUTNUMBEROFDOSESREMAINING` enabled. `doses_remaining` is an Integer for a plain count and ICE's text otherwise (e.g. `"Recurring"`). Each evaluation gets the `series` the dose was evaluated against. All new keys are left out when ICE does not send them.
|
|
8
|
+
- Season `starts_on` and `ends_on` are ISO dates read from the trailing `(MM/DD/YYYY - MM/DD/YYYY)` of ICE's season display name, because ICE sends no structured season dates. Both are `nil` if the name does not end in a valid range, e.g. if ICE changes that format. They are passed through as ICE sends them: an `ends_on` of `2100-01-01` is ICE's open-ended end date, and for influenza ICE may return a default season code and label (e.g. `20152016_INFLUENZA_SEASON`) with the current season's dates. Rely on the dates, not the code.
|
|
9
|
+
|
|
3
10
|
## [0.4.0] - 2026-10-06
|
|
4
11
|
|
|
5
12
|
- Expose ICE's per-dose evaluations under `evaluations` in the result, with one entry per administered dose and vaccine group it counts toward (so a combination vaccine has several). Each entry has the caller's event id, CVX, administration date, vaccine group, dose number, validity, evaluation status, and reasons. Codes are passed through as ICE returns them. `recommendations` and `simplified_status` are unchanged.
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Given a patient's demographics and immunization history, `HlnIce` builds the
|
|
|
6
6
|
VMR (Virtual Medical Record) request, calls the ICE evaluation endpoint, and
|
|
7
7
|
parses the response into:
|
|
8
8
|
|
|
9
|
-
- a full list of **recommendations** (one per vaccine group, with status, reasons, and
|
|
9
|
+
- a full list of **recommendations** (one per vaccine group, with status, reasons, administration intervals, and, when the ICE server outputs them, schedule authorities, series, season, and doses remaining),
|
|
10
10
|
- the **evaluations** of each administered dose (per vaccine group, with dose number, validity, and reasons), and
|
|
11
11
|
- a **simplified status** map keyed by vaccine group — e.g. `{ ipv_opv: "overdue", mmr: "compliant" }`.
|
|
12
12
|
|
|
@@ -115,7 +115,26 @@ On success:
|
|
|
115
115
|
schedule_authorities: [
|
|
116
116
|
{ code: "ACIP_CDC", name: "Advisory Committee on Immunization Practices / Centers for Disease Control and Prevention" },
|
|
117
117
|
{ code: "AAP", name: "American Academy of Pediatrics" }
|
|
118
|
-
]
|
|
118
|
+
],
|
|
119
|
+
# Only present when the ICE server has ICE_OUTPUTSERIESINFORMATION enabled.
|
|
120
|
+
# selection is "unambiguous", "best_guess", or "alternative".
|
|
121
|
+
series: { code: "...", name: "...", selection: "best_guess" }, # e.g. HEP_B_ADULT_3_DOSE_SERIES
|
|
122
|
+
# Only present for seasonal vaccines (e.g. influenza, COVID-19) with series output on
|
|
123
|
+
# starts_on/ends_on are read from the end of ICE's season name, e.g.
|
|
124
|
+
# "2026-2027 COVID-19 Season (08/28/2026 - 01/01/2100)", because ICE sends
|
|
125
|
+
# no structured season dates. They are nil if the name is not in that format.
|
|
126
|
+
# ICE uses an end of 2100-01-01 for an open-ended season. For influenza, ICE
|
|
127
|
+
# may send a default code and label (e.g. 20152016_INFLUENZA_SEASON) with the
|
|
128
|
+
# current season's dates; the dates are the ones to rely on.
|
|
129
|
+
season: {
|
|
130
|
+
code: "COVID_19_AUG_2026_SEASON",
|
|
131
|
+
name: "2026-2027 COVID-19 Season (08/28/2026 - 01/01/2100)",
|
|
132
|
+
starts_on: "2026-08-28",
|
|
133
|
+
ends_on: "2100-01-01"
|
|
134
|
+
},
|
|
135
|
+
# Only present when ICE_OUTPUTNUMBEROFDOSESREMAINING is also enabled. An Integer
|
|
136
|
+
# for a plain count, otherwise ICE's text, e.g. "Recurring".
|
|
137
|
+
doses_remaining: 3
|
|
119
138
|
}
|
|
120
139
|
],
|
|
121
140
|
# One entry per administered dose and vaccine group it counts toward, so a
|
|
@@ -129,7 +148,9 @@ On success:
|
|
|
129
148
|
dose_number: 1, # nil if ICE omits it
|
|
130
149
|
valid: true,
|
|
131
150
|
status: { code: "VALID", name: "Valid Dose" }, # e.g. VALID, INVALID, ACCEPTED
|
|
132
|
-
reasons: [] # e.g. [{ code: "BELOW_MINIMUM_INTERVAL", name: "Below Minimum Interval" }]
|
|
151
|
+
reasons: [], # e.g. [{ code: "BELOW_MINIMUM_INTERVAL", name: "Below Minimum Interval" }]
|
|
152
|
+
# The series the dose was evaluated against. Only present with series output on.
|
|
153
|
+
series: { code: "...", name: "...", selection: "unambiguous" } # e.g. MMR_2_DOSE_SERIES
|
|
133
154
|
}
|
|
134
155
|
],
|
|
135
156
|
simplified_status: { ipv_opv: "overdue" }
|
|
@@ -145,6 +166,10 @@ On failure (service error, timeout after retries, or unparseable response):
|
|
|
145
166
|
|
|
146
167
|
Always branch on `result[:success]` before reading `result[:data]`.
|
|
147
168
|
|
|
169
|
+
Optional keys (`intervals`, `schedule_authorities`, `series`, `season`,
|
|
170
|
+
`doses_remaining`) are left out when ICE does not send them, so reading one
|
|
171
|
+
returns `nil`.
|
|
172
|
+
|
|
148
173
|
### Simplified status values
|
|
149
174
|
|
|
150
175
|
`simplified_status` maps each recognized vaccine group to one of:
|
data/hln_ice.gemspec
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/hln_ice/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "hln_ice"
|
|
7
|
+
spec.version = HlnIce::VERSION
|
|
8
|
+
spec.authors = ["Sam Sohn"]
|
|
9
|
+
spec.email = ["sam@primary.health"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Ruby client for the HLN ICE (Immunization Calculation Engine) OpenCDS service."
|
|
12
|
+
spec.description = "Evaluates a patient's immunization history against the HLN ICE forecasting " \
|
|
13
|
+
"rules and returns both the raw recommendations and a simplified status mapping."
|
|
14
|
+
spec.homepage = "https://github.com/PrimaryDotHealth/hln_ice"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
17
|
+
|
|
18
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
19
|
+
|
|
20
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
23
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
24
|
+
|
|
25
|
+
# Specify which files should be added to the gem when it is released.
|
|
26
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
27
|
+
spec.files = Dir.chdir(__dir__) do
|
|
28
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
29
|
+
(File.expand_path(f) == __FILE__) ||
|
|
30
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
spec.bindir = "exe"
|
|
34
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
35
|
+
spec.require_paths = ["lib"]
|
|
36
|
+
|
|
37
|
+
spec.add_dependency "base64", "~> 0.2"
|
|
38
|
+
spec.add_dependency "httparty", "~> 0.21"
|
|
39
|
+
spec.add_dependency "nokogiri", "~> 1.15"
|
|
40
|
+
|
|
41
|
+
# For more information and examples about making a new gem, check out our
|
|
42
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
|
43
|
+
end
|
|
@@ -16,8 +16,8 @@ module HlnIce
|
|
|
16
16
|
events = patient.xpath("./clinicalStatements/substanceAdministrationEvents/substanceAdministrationEvent")
|
|
17
17
|
|
|
18
18
|
events.flat_map do |event|
|
|
19
|
-
event_id = event.
|
|
20
|
-
cvx = event.
|
|
19
|
+
event_id = event.at_xpath("./id")&.[]("root")
|
|
20
|
+
cvx = event.at_xpath("./substance/substanceCode")&.[]("code")
|
|
21
21
|
|
|
22
22
|
event.xpath("./relatedClinicalStatement/substanceAdministrationEvent").filter_map do |component|
|
|
23
23
|
parse_evaluation(component, event_id:, cvx:)
|
|
@@ -31,19 +31,20 @@ module HlnIce
|
|
|
31
31
|
# Build the evaluation for one vaccine group from a nested event.
|
|
32
32
|
# Returns nil when the event has no evaluation status observation.
|
|
33
33
|
def parse_evaluation(component, event_id:, cvx:)
|
|
34
|
-
observation = component
|
|
35
|
-
|
|
34
|
+
observation = vaccine_group_observation(component)
|
|
35
|
+
status = observation&.at_xpath("./observationValue/concept")
|
|
36
|
+
return unless status
|
|
36
37
|
|
|
37
|
-
focus = observation.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
focus = observation.at_xpath("./observationFocus")
|
|
39
|
+
dose_number = component.at_xpath("./doseNumber")&.[]("value")
|
|
40
|
+
valid = component.at_xpath("./isValid")&.[]("value")
|
|
41
|
+
series = SeriesParser.new(component).series
|
|
41
42
|
|
|
42
|
-
{
|
|
43
|
+
evaluation = {
|
|
43
44
|
event_id:,
|
|
44
45
|
cvx:,
|
|
45
|
-
administered_on: format_ice_date(component.
|
|
46
|
-
vaccine_group: { code: focus
|
|
46
|
+
administered_on: format_ice_date(component.at_xpath("./administrationTimeInterval")&.[]("low")),
|
|
47
|
+
vaccine_group: { code: focus["code"], name: focus["displayName"] },
|
|
47
48
|
dose_number: dose_number&.to_i,
|
|
48
49
|
valid: valid.nil? ? nil : valid == "true",
|
|
49
50
|
status: { code: status["code"], name: status["displayName"] },
|
|
@@ -51,6 +52,8 @@ module HlnIce
|
|
|
51
52
|
{ code: interpretation["code"], name: interpretation["displayName"] }
|
|
52
53
|
end
|
|
53
54
|
}
|
|
55
|
+
evaluation[:series] = series if series
|
|
56
|
+
evaluation
|
|
54
57
|
end
|
|
55
58
|
end
|
|
56
59
|
end
|
|
@@ -33,132 +33,125 @@ module HlnIce
|
|
|
33
33
|
attr_reader :response_body
|
|
34
34
|
|
|
35
35
|
def parse_xml_response(xml_string)
|
|
36
|
-
# Parse the XML
|
|
37
36
|
doc = Nokogiri::XML(xml_string)
|
|
38
37
|
|
|
39
38
|
# Remove namespaces to simplify parsing
|
|
40
39
|
doc.remove_namespaces!
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
patient = doc.xpath("//patient").first
|
|
41
|
+
patient = doc.at_xpath("/cdsOutput/vmrOutput/patient")
|
|
44
42
|
return { raw_xml: xml_string } unless patient
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
# A proposal may contain other observation results (e.g. schedule
|
|
67
|
-
# authorities), in any order, so select the one with a status concept.
|
|
68
|
-
observation = proposal.xpath(".//observationResult[observationValue/concept]").first
|
|
69
|
-
next unless observation
|
|
70
|
-
|
|
71
|
-
# Get recommendation status
|
|
72
|
-
status_element = observation.xpath(".//observationValue/concept").first
|
|
73
|
-
|
|
74
|
-
status_code = status_element["code"]
|
|
75
|
-
status_name = status_element["displayName"]
|
|
76
|
-
|
|
77
|
-
# Get interpretation reasons
|
|
78
|
-
reasons = []
|
|
79
|
-
observation.xpath(".//interpretation").each do |interpretation|
|
|
80
|
-
reason = {
|
|
81
|
-
code: interpretation["code"],
|
|
82
|
-
name: interpretation["displayName"]
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
# Add supplemental text if available
|
|
86
|
-
reason[:supplemental_text] = interpretation["originalText"] if interpretation["originalText"]
|
|
87
|
-
|
|
88
|
-
reasons << reason
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Get administration time intervals if available
|
|
92
|
-
proposed_interval = proposal.xpath("./proposedAdministrationTimeInterval").first
|
|
93
|
-
valid_interval = proposal.xpath("./validAdministrationTimeInterval").first
|
|
94
|
-
|
|
95
|
-
intervals = {}
|
|
96
|
-
if proposed_interval
|
|
97
|
-
intervals[:proposed] = {
|
|
98
|
-
low: format_ice_date(proposed_interval["low"]),
|
|
99
|
-
high: format_ice_date(proposed_interval["high"])
|
|
100
|
-
}.compact
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
if valid_interval
|
|
104
|
-
intervals[:valid] = {
|
|
105
|
-
low: format_ice_date(valid_interval["low"]),
|
|
106
|
-
high: format_ice_date(valid_interval["high"])
|
|
107
|
-
}.compact
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Build the recommendation object
|
|
111
|
-
recommendation = {
|
|
112
|
-
vaccine: {
|
|
113
|
-
code: vaccine_code,
|
|
114
|
-
code_system: vaccine_code_system,
|
|
115
|
-
name: vaccine_name
|
|
116
|
-
},
|
|
117
|
-
status: {
|
|
118
|
-
code: status_code,
|
|
119
|
-
name: status_name
|
|
120
|
-
},
|
|
121
|
-
reasons:
|
|
122
|
-
}
|
|
44
|
+
recommendations = parse_recommendations(patient)
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
raw_xml: xml_string,
|
|
48
|
+
patient: parse_patient(patient),
|
|
49
|
+
recommendations:,
|
|
50
|
+
evaluations: EvaluationsParser.new(patient).evaluations,
|
|
51
|
+
simplified_status: SimplifiedStatus.new(recommendations).to_h
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_patient(patient)
|
|
56
|
+
birth_time = patient.at_xpath("./demographics/birthTime")&.[]("value")
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
id: patient.at_xpath("./id")&.[]("extension"),
|
|
60
|
+
birth_date: format_ice_date(birth_time) || birth_time,
|
|
61
|
+
gender: patient.at_xpath("./demographics/gender")&.[]("code")
|
|
62
|
+
}.compact
|
|
63
|
+
end
|
|
123
64
|
|
|
124
|
-
|
|
125
|
-
|
|
65
|
+
def parse_recommendations(patient)
|
|
66
|
+
proposals = patient.xpath(
|
|
67
|
+
"./clinicalStatements/substanceAdministrationProposals/substanceAdministrationProposal"
|
|
68
|
+
)
|
|
126
69
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
schedule_authorities = parse_schedule_authorities(proposal)
|
|
130
|
-
recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
|
|
70
|
+
proposals.filter_map { |proposal| parse_recommendation(proposal) }
|
|
71
|
+
end
|
|
131
72
|
|
|
132
|
-
|
|
73
|
+
# Returns nil when the proposal has no vaccine group status observation.
|
|
74
|
+
def parse_recommendation(proposal)
|
|
75
|
+
substance = proposal.at_xpath("./substance/substanceCode")
|
|
76
|
+
observation = vaccine_group_observation(proposal)
|
|
77
|
+
status = observation&.at_xpath("./observationValue/concept")
|
|
78
|
+
return unless substance && status
|
|
79
|
+
|
|
80
|
+
recommendation = {
|
|
81
|
+
vaccine: {
|
|
82
|
+
code: substance["code"],
|
|
83
|
+
code_system: substance["codeSystem"],
|
|
84
|
+
name: substance["displayName"]
|
|
85
|
+
},
|
|
86
|
+
status: {
|
|
87
|
+
code: status["code"],
|
|
88
|
+
name: status["displayName"]
|
|
89
|
+
},
|
|
90
|
+
reasons: parse_reasons(observation)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
intervals = parse_intervals(proposal)
|
|
94
|
+
recommendation[:intervals] = intervals if present?(intervals)
|
|
95
|
+
|
|
96
|
+
# Only add schedule authorities if the service returned them
|
|
97
|
+
# (requires the ICE outputScheduleAuthorities property)
|
|
98
|
+
schedule_authorities = parse_schedule_authorities(proposal)
|
|
99
|
+
recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
|
|
100
|
+
|
|
101
|
+
recommendation.merge(parse_series(proposal))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_reasons(observation)
|
|
105
|
+
observation.xpath("./interpretation").map do |interpretation|
|
|
106
|
+
reason = {
|
|
107
|
+
code: interpretation["code"],
|
|
108
|
+
name: interpretation["displayName"]
|
|
109
|
+
}
|
|
110
|
+
reason[:supplemental_text] = interpretation["originalText"] if interpretation["originalText"]
|
|
111
|
+
reason
|
|
133
112
|
end
|
|
113
|
+
end
|
|
134
114
|
|
|
135
|
-
|
|
115
|
+
def parse_intervals(proposal)
|
|
136
116
|
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
117
|
+
proposed: parse_interval(proposal.at_xpath("./proposedAdministrationTimeInterval")),
|
|
118
|
+
valid: parse_interval(proposal.at_xpath("./validAdministrationTimeInterval"))
|
|
119
|
+
}.compact
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse_interval(interval)
|
|
123
|
+
return unless interval
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
low: format_ice_date(interval["low"]),
|
|
127
|
+
high: format_ice_date(interval["high"])
|
|
128
|
+
}.compact
|
|
147
129
|
end
|
|
148
130
|
|
|
149
131
|
# Extract the schedule authorities (e.g. ACIP_CDC, AAP) for a proposal.
|
|
150
132
|
# Returns an empty array when the service did not include them.
|
|
151
133
|
def parse_schedule_authorities(proposal)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
134
|
+
observations = related_observations(proposal, focus_code_system: ICE_OUTPUT_FOCUS_CODE_SYSTEM,
|
|
135
|
+
focus_code: "ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES")
|
|
155
136
|
|
|
156
|
-
|
|
137
|
+
observations.xpath("./interpretation").map do |interpretation|
|
|
157
138
|
{
|
|
158
139
|
code: interpretation["code"],
|
|
159
140
|
name: interpretation["displayName"]
|
|
160
141
|
}
|
|
161
142
|
end
|
|
162
143
|
end
|
|
144
|
+
|
|
145
|
+
# Only the fields ICE sent, so responses without series output parse as
|
|
146
|
+
# before.
|
|
147
|
+
def parse_series(proposal)
|
|
148
|
+
series = SeriesParser.new(proposal)
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
series: series.series,
|
|
152
|
+
season: series.season,
|
|
153
|
+
doses_remaining: series.doses_remaining
|
|
154
|
+
}.compact
|
|
155
|
+
end
|
|
163
156
|
end
|
|
164
157
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HlnIce
|
|
4
|
+
# Extracts the series ICE selected for display from a recommendation or dose
|
|
5
|
+
# evaluation, along with its season and doses remaining. ICE only sends these
|
|
6
|
+
# when the ICE_OUTPUTSERIESINFORMATION and ICE_OUTPUTNUMBEROFDOSESREMAINING
|
|
7
|
+
# service settings are on; each value is nil when ICE did not send it.
|
|
8
|
+
class SeriesParser
|
|
9
|
+
include Support
|
|
10
|
+
|
|
11
|
+
SERIES_DISPLAY_TYPE_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.501"
|
|
12
|
+
SERIES_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.10"
|
|
13
|
+
SEASON_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.11"
|
|
14
|
+
SEASON_DATE_RANGE = %r{\((\d{2}/\d{2}/\d{4}) - (\d{2}/\d{2}/\d{4})\)\z}
|
|
15
|
+
|
|
16
|
+
# In order of preference, so a selected series wins over an alternative.
|
|
17
|
+
SELECTIONS = {
|
|
18
|
+
"SERIES_DISPLAY_UNAMBIGUOUS" => "unambiguous",
|
|
19
|
+
"SERIES_DISPLAY_BEST_GUESS" => "best_guess",
|
|
20
|
+
"SERIES_DISPLAY_ALTERNATIVE" => "alternative"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(statement)
|
|
24
|
+
@statement = statement
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def series
|
|
28
|
+
concept = selected_observation&.at_xpath("./observationValue/concept[@codeSystem='#{SERIES_CODE_SYSTEM}']")
|
|
29
|
+
return unless concept
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
code: concept["code"],
|
|
33
|
+
name: concept["displayName"],
|
|
34
|
+
selection: SELECTIONS.fetch(selection_code(selected_observation))
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def season
|
|
39
|
+
concept = selected_observation_detail("SEASON", SEASON_CODE_SYSTEM)
|
|
40
|
+
&.at_xpath("./observationValue/concept[@codeSystem='#{SEASON_CODE_SYSTEM}']")
|
|
41
|
+
return unless concept
|
|
42
|
+
|
|
43
|
+
starts_on, ends_on = season_dates(concept["displayName"])
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
code: concept["code"],
|
|
47
|
+
name: concept["displayName"],
|
|
48
|
+
starts_on:,
|
|
49
|
+
ends_on:
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# An Integer when ICE sends a plain count, otherwise ICE's text as is
|
|
54
|
+
# (e.g. "Recurring").
|
|
55
|
+
def doses_remaining
|
|
56
|
+
value = selected_observation_detail("NUMBER_OF_DOSES_REMAINING", SERIES_CODE_SYSTEM)
|
|
57
|
+
&.at_xpath("./observationValue/text")&.[]("value")
|
|
58
|
+
|
|
59
|
+
value&.match?(/\A\d+\z/) ? value.to_i : value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
attr_reader :statement
|
|
64
|
+
|
|
65
|
+
# ICE sends no structured season dates, only a trailing
|
|
66
|
+
# "(MM/DD/YYYY - MM/DD/YYYY)" in the season's display name. Returns ISO
|
|
67
|
+
# dates, or nils when the name does not end in a valid range.
|
|
68
|
+
def season_dates(name)
|
|
69
|
+
range = SEASON_DATE_RANGE.match(name.to_s)
|
|
70
|
+
return [nil, nil] unless range
|
|
71
|
+
|
|
72
|
+
range.captures.map { |date| Date.strptime(date, "%m/%d/%Y").iso8601 }
|
|
73
|
+
rescue Date::Error
|
|
74
|
+
[nil, nil]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def selected_observation
|
|
78
|
+
return @selected_observation if defined?(@selected_observation)
|
|
79
|
+
|
|
80
|
+
@selected_observation = series_selections.min_by do |observation|
|
|
81
|
+
SELECTIONS.keys.index(selection_code(observation))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def series_selections
|
|
86
|
+
options = related_observations(statement, focus_code_system: ICE_OUTPUT_FOCUS_CODE_SYSTEM,
|
|
87
|
+
focus_code: "SERIES_DISPLAY_OPTIONS")
|
|
88
|
+
|
|
89
|
+
selections = options.flat_map do |option|
|
|
90
|
+
related_observations(option, focus_code_system: SERIES_DISPLAY_TYPE_CODE_SYSTEM).to_a
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
selections.select { |observation| SELECTIONS.key?(selection_code(observation)) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def selection_code(observation)
|
|
97
|
+
observation.at_xpath("./observationFocus")["code"]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def selected_observation_detail(focus_code, focus_code_system)
|
|
101
|
+
return unless selected_observation
|
|
102
|
+
|
|
103
|
+
related_observations(selected_observation, focus_code_system:, focus_code:).first
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/hln_ice/support.rb
CHANGED
|
@@ -6,7 +6,26 @@ module HlnIce
|
|
|
6
6
|
# Helpers shared by the request builder and response parsers. Included as
|
|
7
7
|
# private instance methods.
|
|
8
8
|
module Support
|
|
9
|
+
VACCINE_GROUP_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.1"
|
|
10
|
+
ICE_OUTPUT_FOCUS_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.500"
|
|
11
|
+
|
|
9
12
|
private
|
|
13
|
+
# The observations a vMR statement relates directly to, selected by their
|
|
14
|
+
# observationFocus rather than by position: ICE orders them differently
|
|
15
|
+
# from one request to the next, and nests further observations in them.
|
|
16
|
+
def related_observations(statement, focus_code_system:, focus_code: nil)
|
|
17
|
+
focus = "@codeSystem='#{focus_code_system}'"
|
|
18
|
+
focus += " and @code='#{focus_code}'" if focus_code
|
|
19
|
+
|
|
20
|
+
statement.xpath("./relatedClinicalStatement/observationResult[observationFocus[#{focus}]]")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The observation carrying ICE's status for the vaccine group, e.g. a
|
|
24
|
+
# recommendation or dose evaluation status. ICE sends one per statement.
|
|
25
|
+
def vaccine_group_observation(statement)
|
|
26
|
+
related_observations(statement, focus_code_system: VACCINE_GROUP_CODE_SYSTEM).first
|
|
27
|
+
end
|
|
28
|
+
|
|
10
29
|
# Plain-Ruby equivalent of ActiveSupport's `blank?`.
|
|
11
30
|
def blank?(value)
|
|
12
31
|
return true if value.nil?
|
data/lib/hln_ice/version.rb
CHANGED
data/lib/hln_ice.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "hln_ice/version"
|
|
4
4
|
require_relative "hln_ice/support"
|
|
5
5
|
require_relative "hln_ice/request_builder"
|
|
6
|
+
require_relative "hln_ice/series_parser"
|
|
6
7
|
require_relative "hln_ice/evaluations_parser"
|
|
7
8
|
require_relative "hln_ice/simplified_status"
|
|
8
9
|
require_relative "hln_ice/response_parser"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hln_ice
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sam Sohn
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: base64
|
|
@@ -66,11 +66,13 @@ files:
|
|
|
66
66
|
- LICENSE.txt
|
|
67
67
|
- README.md
|
|
68
68
|
- Rakefile
|
|
69
|
+
- hln_ice.gemspec
|
|
69
70
|
- lib/hln_ice.rb
|
|
70
71
|
- lib/hln_ice/client.rb
|
|
71
72
|
- lib/hln_ice/evaluations_parser.rb
|
|
72
73
|
- lib/hln_ice/request_builder.rb
|
|
73
74
|
- lib/hln_ice/response_parser.rb
|
|
75
|
+
- lib/hln_ice/series_parser.rb
|
|
74
76
|
- lib/hln_ice/simplified_status.rb
|
|
75
77
|
- lib/hln_ice/support.rb
|
|
76
78
|
- lib/hln_ice/version.rb
|