Skip to content

Commit 0076cc4

Browse files
james-leinasclaudenathan-stender
authored
feat: Cytiva Biacore T200 Evaluation -- Update parser to extract kinetics data (kd, ka, RMax, chi-squared, errors) (#1183)
Update parser to extract kinetics data (kd, ka, RMax, chi-squared, errors) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Nathan Stender <nathan.stender@benchling.com>
1 parent c44003b commit 0076cc4

18 files changed

Lines changed: 233093 additions & 258 deletions

src/allotropy/allotrope/schema_mappers/adm/binding_affinity_analyzer/benchling/_2024/_12/binding_affinity_analyzer.py

Lines changed: 84 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from allotropy.parsers.utils.calculated_data_documents.definition import (
4747
CalculatedDocument,
4848
)
49-
from allotropy.parsers.utils.values import assert_not_none, has_value, quantity_or_none
49+
from allotropy.parsers.utils.values import assert_not_none, quantity_or_none
5050
from allotropy.types import DictType
5151

5252

@@ -108,6 +108,19 @@ class DeviceControlDocument:
108108
device_control_custom_info: DictType | None = None
109109

110110

111+
@dataclass(frozen=True)
112+
class ProcessedData:
113+
"""Represents a single processed data document (one analysis model)."""
114+
115+
model_name: str
116+
binding_on_rate_measurement_datum__kon_: float | None = None
117+
binding_off_rate_measurement_datum__koff_: float | None = None
118+
equilibrium_dissociation_constant__kd_: float | None = None
119+
maximum_binding_capacity__rmax_: float | None = None
120+
processed_data_custom_info: DictType | None = None
121+
data_processing_document: DictType | None = None
122+
123+
111124
@dataclass(frozen=True)
112125
class Measurement:
113126
identifier: str
@@ -126,19 +139,12 @@ class Measurement:
126139
# Sensorgram
127140
sensorgram_data_cube: DataCube | None = None
128141

129-
# Processed Data
130-
binding_on_rate_measurement_datum__kon_: float | None = None
131-
binding_off_rate_measurement_datum__koff_: float | None = None
132-
equilibrium_dissociation_constant__kd_: float | None = None
133-
maximum_binding_capacity__rmax_: float | None = None
134-
processed_data_custom_info: DictType | None = None
142+
# Processed Data - supports multiple analysis models
143+
processed_data: list[ProcessedData] | None = None
135144

136145
# Report point
137146
report_point_data: list[ReportPoint] | None = None
138147

139-
# Data processing
140-
data_processing_document: DictType | None = None
141-
142148

143149
@dataclass(frozen=True)
144150
class MeasurementGroup:
@@ -249,60 +255,75 @@ def _get_measurement_document_item(
249255
def _get_surface_plasmon_resonance_measurement_document(
250256
self, measurement: Measurement, metadata: Metadata
251257
) -> MeasurementDocumentItem:
252-
processed_data_document = ProcessedDataDocumentItem(
253-
data_processing_document=(
254-
{
255-
key: value
256-
for key, value in measurement.data_processing_document.items()
257-
if value is not None
258-
}
259-
if measurement.data_processing_document
260-
else None
261-
),
262-
binding_on_rate_measurement_datum__kon_=quantity_or_none(
263-
TQuantityValuePerMolarPerSecond,
264-
measurement.binding_on_rate_measurement_datum__kon_,
265-
),
266-
binding_off_rate_measurement_datum__koff_=quantity_or_none(
267-
TQuantityValuePerSecond,
268-
measurement.binding_off_rate_measurement_datum__koff_,
269-
),
270-
equilibrium_dissociation_constant__kd_=quantity_or_none(
271-
TQuantityValueMolar,
272-
measurement.equilibrium_dissociation_constant__kd_,
273-
),
274-
maximum_binding_capacity__rmax_=quantity_or_none(
275-
TQuantityValueResponseUnit,
276-
measurement.maximum_binding_capacity__rmax_,
277-
),
278-
report_point_aggregate_document=(
279-
ReportPointAggregateDocument(
280-
report_point_document=[
281-
add_custom_information_document(
282-
ReportPointDocumentItem(
283-
report_point_identifier=report_point.identifier,
284-
identifier_role=report_point.identifier_role,
285-
absolute_resonance=TQuantityValueResponseUnit(
286-
value=report_point.absolute_resonance
287-
),
288-
relative_resonance=quantity_or_none(
289-
TQuantityValueResponseUnit,
290-
report_point.relative_resonance,
291-
),
292-
time_setting=TQuantityValueSecondTime(
293-
value=report_point.time_setting
294-
),
258+
# Report points are measurement-level data but the schema nests them
259+
# inside ProcessedDataDocumentItem, so we attach them to the first entry.
260+
report_point_aggregate = (
261+
ReportPointAggregateDocument(
262+
report_point_document=[
263+
add_custom_information_document(
264+
ReportPointDocumentItem(
265+
report_point_identifier=report_point.identifier,
266+
identifier_role=report_point.identifier_role,
267+
absolute_resonance=TQuantityValueResponseUnit(
268+
value=report_point.absolute_resonance
295269
),
296-
custom_info_doc=report_point.custom_info,
297-
)
298-
for report_point in measurement.report_point_data
299-
]
300-
)
301-
if measurement.report_point_data
302-
else None
303-
),
270+
relative_resonance=quantity_or_none(
271+
TQuantityValueResponseUnit,
272+
report_point.relative_resonance,
273+
),
274+
time_setting=TQuantityValueSecondTime(
275+
value=report_point.time_setting
276+
),
277+
),
278+
custom_info_doc=report_point.custom_info,
279+
)
280+
for report_point in measurement.report_point_data
281+
]
282+
)
283+
if measurement.report_point_data
284+
else None
304285
)
305286

287+
processed_data_documents = []
288+
if measurement.processed_data:
289+
for idx, proc_data in enumerate(measurement.processed_data):
290+
doc = ProcessedDataDocumentItem(
291+
data_processing_document=(
292+
{
293+
key: value
294+
for key, value in proc_data.data_processing_document.items()
295+
if value is not None
296+
}
297+
if proc_data.data_processing_document
298+
else None
299+
),
300+
binding_on_rate_measurement_datum__kon_=quantity_or_none(
301+
TQuantityValuePerMolarPerSecond,
302+
proc_data.binding_on_rate_measurement_datum__kon_,
303+
),
304+
binding_off_rate_measurement_datum__koff_=quantity_or_none(
305+
TQuantityValuePerSecond,
306+
proc_data.binding_off_rate_measurement_datum__koff_,
307+
),
308+
equilibrium_dissociation_constant__kd_=quantity_or_none(
309+
TQuantityValueMolar,
310+
proc_data.equilibrium_dissociation_constant__kd_,
311+
),
312+
maximum_binding_capacity__rmax_=quantity_or_none(
313+
TQuantityValueResponseUnit,
314+
proc_data.maximum_binding_capacity__rmax_,
315+
),
316+
report_point_aggregate_document=report_point_aggregate
317+
if idx == 0
318+
else None,
319+
)
320+
processed_data_documents.append(
321+
add_custom_information_document(
322+
doc,
323+
custom_info_doc=proc_data.processed_data_custom_info,
324+
)
325+
)
326+
306327
return MeasurementDocumentItem(
307328
measurement_identifier=measurement.identifier,
308329
sample_document=add_custom_information_document(
@@ -361,14 +382,9 @@ def _get_surface_plasmon_resonance_measurement_document(
361382
),
362383
processed_data_aggregate_document=(
363384
ProcessedDataAggregateDocument(
364-
processed_data_document=[
365-
add_custom_information_document(
366-
processed_data_document,
367-
custom_info_doc=measurement.processed_data_custom_info,
368-
)
369-
]
385+
processed_data_document=processed_data_documents
370386
)
371-
if has_value(processed_data_document)
387+
if processed_data_documents
372388
else None
373389
),
374390
)

src/allotropy/parsers/cytiva_biacore_insight/cytiva_biacore_insight_data_creator.py

Lines changed: 89 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
MeasurementGroup,
2121
MeasurementType,
2222
Metadata,
23+
ProcessedData,
2324
ReportPoint,
2425
)
2526
from allotropy.parsers.constants import NOT_APPLICABLE
@@ -113,77 +114,103 @@ def _get_measurements(
113114
) -> list[Measurement]:
114115
data_processing_document = dict(metadata.data_processing_document or {})
115116

116-
return [
117-
Measurement(
118-
identifier=measurement.identifier,
119-
sample_identifier=measurement.sample_identifier,
120-
type_=MeasurementType.SURFACE_PLASMON_RESONANCE,
121-
method_name=measurement.method_name,
122-
ligand_identifier=measurement.ligand_identifier,
123-
device_control_document=measurement.device_control_document,
124-
sample_custom_info=measurement.sample_custom_info,
125-
binding_on_rate_measurement_datum__kon_=(
126-
measurement.kinetics.binding_on_rate_measurement_datum
127-
),
128-
binding_off_rate_measurement_datum__koff_=(
129-
measurement.kinetics.binding_off_rate_measurement_datum
130-
),
131-
equilibrium_dissociation_constant__kd_=(
132-
measurement.kinetics.equilibrium_dissociation_constant
133-
),
134-
maximum_binding_capacity__rmax_=(
135-
measurement.kinetics.maximum_binding_capacity
136-
),
137-
processed_data_custom_info=_clean_custom_info(
138-
{
139-
(
140-
"Affinity Chi squared"
141-
if measurement.kinetics.is_affinity_measurement
142-
else "Kinetics Chi squared"
143-
): (
144-
TQuantityValue(value=v, unit="RU^2")
145-
if (v := measurement.kinetics.kinetics_chi_squared) is not None
146-
else None
117+
measurements = []
118+
for measurement in measurement_data:
119+
processed_data_list = []
120+
121+
if measurement.kinetics:
122+
for kinetics in measurement.kinetics:
123+
chi_squared_label = (
124+
"Affinity Chi squared"
125+
if "affinity" in kinetics.model_name.lower()
126+
else "Kinetics Chi squared"
127+
)
128+
129+
processed_data = ProcessedData(
130+
model_name=kinetics.model_name,
131+
binding_on_rate_measurement_datum__kon_=(
132+
kinetics.binding_on_rate_measurement_datum
147133
),
148-
"tc": quantity_or_none(
149-
TQuantityValueUnitless, measurement.kinetics.tc
134+
binding_off_rate_measurement_datum__koff_=(
135+
kinetics.binding_off_rate_measurement_datum
150136
),
151-
"offset": quantity_or_none(
152-
TQuantityValueResponseUnit,
153-
measurement.kinetics.offset,
137+
equilibrium_dissociation_constant__kd_=(
138+
kinetics.equilibrium_dissociation_constant
154139
),
155-
}
156-
),
157-
report_point_data=[
158-
ReportPoint(
159-
identifier=rp.identifier,
160-
identifier_role=rp.identifier_role,
161-
absolute_resonance=rp.absolute_resonance,
162-
time_setting=rp.time_setting,
163-
relative_resonance=rp.relative_resonance,
164-
custom_info=_clean_custom_info(
140+
maximum_binding_capacity__rmax_=(kinetics.maximum_binding_capacity),
141+
processed_data_custom_info=_clean_custom_info(
165142
{
166-
"Step purpose": rp.step_purpose,
167-
"Window": quantity_or_none(
168-
TQuantityValueSecondTime, rp.window
143+
chi_squared_label: (
144+
TQuantityValue(value=v, unit="RU^2")
145+
if (v := kinetics.kinetics_chi_squared) is not None
146+
else None
147+
),
148+
"tc": quantity_or_none(TQuantityValueUnitless, kinetics.tc),
149+
"U-value": quantity_or_none(
150+
TQuantityValueUnitless, kinetics.u_value
151+
),
152+
"offset": quantity_or_none(
153+
TQuantityValueResponseUnit,
154+
kinetics.offset,
169155
),
170-
"Baseline": rp.baseline,
171156
}
172157
),
158+
data_processing_document=_clean_custom_info(
159+
{
160+
**data_processing_document,
161+
"Acceptance State": kinetics.acceptance_state,
162+
"Curve Markers": kinetics.curve_markers,
163+
"Kinetics Model": kinetics.model_name,
164+
}
165+
),
166+
)
167+
processed_data_list.append(processed_data)
168+
else:
169+
# If no kinetics data, create a single ProcessedData with just data processing info
170+
processed_data_list.append(
171+
ProcessedData(
172+
model_name="N/A", # No model when there's no kinetics data
173+
data_processing_document=_clean_custom_info(
174+
data_processing_document
175+
)
176+
if data_processing_document
177+
else None,
173178
)
174-
for rp in measurement.report_point_data
175-
],
176-
data_processing_document=_clean_custom_info(
177-
{
178-
**data_processing_document,
179-
"Acceptance State": measurement.kinetics.acceptance_state,
180-
"Curve Markers": measurement.kinetics.curve_markers,
181-
"Kinetics Model": measurement.kinetics.kinetics_model,
182-
}
183-
),
179+
)
180+
181+
measurements.append(
182+
Measurement(
183+
identifier=measurement.identifier,
184+
sample_identifier=measurement.sample_identifier,
185+
type_=MeasurementType.SURFACE_PLASMON_RESONANCE,
186+
method_name=measurement.method_name,
187+
ligand_identifier=measurement.ligand_identifier,
188+
device_control_document=measurement.device_control_document,
189+
sample_custom_info=measurement.sample_custom_info,
190+
processed_data=processed_data_list,
191+
report_point_data=[
192+
ReportPoint(
193+
identifier=rp.identifier,
194+
identifier_role=rp.identifier_role,
195+
absolute_resonance=rp.absolute_resonance,
196+
time_setting=rp.time_setting,
197+
relative_resonance=rp.relative_resonance,
198+
custom_info=_clean_custom_info(
199+
{
200+
"Step purpose": rp.step_purpose,
201+
"Window": quantity_or_none(
202+
TQuantityValueSecondTime, rp.window
203+
),
204+
"Baseline": rp.baseline,
205+
}
206+
),
207+
)
208+
for rp in measurement.report_point_data
209+
],
210+
)
184211
)
185-
for measurement in measurement_data
186-
]
212+
213+
return measurements
187214

188215

189216
def create_calculated_data(data: Data) -> list[CalculatedDocument]:

0 commit comments

Comments
 (0)