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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80a72761b4c53974d8784c926ca86d27953488ac8c6e99e9bdb386447b8a3f98
4
- data.tar.gz: 3952414a0bf97c45cc2a7009c4b16b1a016bb866697821c1bf69c1b60bcc05ac
3
+ metadata.gz: 963f6531ca9e78e0f66d8ede2c7b7e34cc57676726ff7fe8069ceeec03a5b304
4
+ data.tar.gz: b164c7a89a955be98cca09d1f4d685166702549bcfec82e1c8e0e6502c359206
5
5
  SHA512:
6
- metadata.gz: 2389561c18f1c9d88f486214bf4bcd5eb6b9b3bc92f08b08fc7d6ac360ccc07360646cece3ddde6c26c5a6a5dddb2026bea28f211ce5f700ace3f1baad4179c8
7
- data.tar.gz: bd197b89a993c6df99f2fad1d586a3da4ce31715388a541f4679ad96a26c2c9635cdebeeef502ba67808fbbb9427c14338bc9fcd0692ca9a84f9ef242128761c
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 administration intervals),
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.xpath("./id").first&.[]("root")
20
- cvx = event.xpath("./substance/substanceCode").first&.[]("code")
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.xpath("./relatedClinicalStatement/observationResult[observationValue/concept]").first
35
- return unless observation
34
+ observation = vaccine_group_observation(component)
35
+ status = observation&.at_xpath("./observationValue/concept")
36
+ return unless status
36
37
 
37
- focus = observation.xpath("./observationFocus").first
38
- status = observation.xpath("./observationValue/concept").first
39
- dose_number = component.xpath("./doseNumber").first&.[]("value")
40
- valid = component.xpath("./isValid").first&.[]("value")
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.xpath("./administrationTimeInterval").first&.[]("low")),
46
- vaccine_group: { code: focus&.[]("code"), name: focus&.[]("displayName") },
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
- # Extract patient information
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
- patient_id = patient.xpath("./id").first&.[]("extension")
47
- birth_time = patient.xpath("./demographics/birthTime").first&.[]("value")
48
- gender = patient.xpath("./demographics/gender").first&.[]("code")
49
-
50
- # Format birth date if present
51
- birth_date = format_ice_date(birth_time)
52
-
53
- # Extract vaccine recommendations
54
- all_recommendations = []
55
-
56
- doc.xpath("//substanceAdministrationProposal").each do |proposal|
57
- # Get the substance code and name
58
- substance_element = proposal.xpath("./substance/substanceCode").first
59
- next unless substance_element
60
-
61
- vaccine_code = substance_element["code"]
62
- vaccine_name = substance_element["displayName"]
63
- vaccine_code_system = substance_element["codeSystem"]
64
-
65
- # Get the clinical observation result carrying the recommendation status.
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
- # Only add intervals if they exist
125
- recommendation[:intervals] = intervals if present?(intervals)
65
+ def parse_recommendations(patient)
66
+ proposals = patient.xpath(
67
+ "./clinicalStatements/substanceAdministrationProposals/substanceAdministrationProposal"
68
+ )
126
69
 
127
- # Only add schedule authorities if the service returned them
128
- # (requires the ICE outputScheduleAuthorities property)
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
- all_recommendations << recommendation
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
- # Build the final result structure
115
+ def parse_intervals(proposal)
136
116
  {
137
- raw_xml: xml_string,
138
- patient: {
139
- id: patient_id,
140
- birth_date: birth_date || birth_time,
141
- gender:
142
- }.compact,
143
- recommendations: all_recommendations, # Keep the original flat list for backward compatibility
144
- evaluations: EvaluationsParser.new(patient).evaluations,
145
- simplified_status: SimplifiedStatus.new(all_recommendations).to_h
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
- authorities = proposal.xpath(
153
- ".//observationResult[observationFocus/@code='ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES']/interpretation"
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
- authorities.map do |interpretation|
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
@@ -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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HlnIce
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.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-07 00:00:00.000000000 Z
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