Forráskód Böngészése

wip, rcsb validation report

Alexander Rose 5 éve
szülő
commit
21cf2d5437

+ 296 - 0
src/mol-model-props/rcsb/representations/validation-report-clashes.ts

@@ -0,0 +1,296 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { Unit, Structure } from '../../../mol-model/structure';
+import { Theme, ThemeRegistryContext, ThemeDataContext } from '../../../mol-theme/theme';
+import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
+import { Vec3 } from '../../../mol-math/linear-algebra';
+import { PickingId } from '../../../mol-geo/geometry/picking';
+import { EmptyLoci, Loci } from '../../../mol-model/loci';
+import { Interval, SortedArray } from '../../../mol-data/int';
+import { RepresentationContext, RepresentationParamsGetter, Representation } from '../../../mol-repr/representation';
+import { UnitsRepresentation, StructureRepresentation, StructureRepresentationStateBuilder, StructureRepresentationProvider, ComplexRepresentation } from '../../../mol-repr/structure/representation';
+import { UnitKind, UnitKindOptions } from '../../../mol-repr/structure/visual/util/common';
+import { VisualContext } from '../../../mol-repr/visual';
+import { createLinkCylinderMesh, LinkCylinderParams, LinkCylinderStyle } from '../../../mol-repr/structure/visual/util/link';
+import { UnitsMeshParams, UnitsVisual, UnitsMeshVisual, StructureGroup } from '../../../mol-repr/structure/units-visual';
+import { VisualUpdateState } from '../../../mol-repr/util';
+import { LocationIterator } from '../../../mol-geo/util/location-iterator';
+import { DataLocation } from '../../../mol-model/location';
+import { ValidationReportProvider, ValidationReport } from '../validation-report';
+import { CustomProperty } from '../../common/custom-property';
+import { ComplexMeshParams, ComplexVisual, ComplexMeshVisual } from '../../../mol-repr/structure/complex-visual';
+import { arrayMax } from '../../../mol-util/array';
+import { UnitIndex } from '../../../mol-model/structure/structure/element/element';
+import { InterUnitGraph } from '../../../mol-math/graph/inter-unit-graph';
+import { ColorTheme } from '../../../mol-theme/color';
+import { ColorNames } from '../../../mol-util/color/names';
+
+//
+
+function createIntraUnitClashCylinderMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: PD.Values<IntraUnitClashParams>, mesh?: Mesh) {
+    if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh)
+
+    const validationReport = ValidationReportProvider.get(unit.model).value!
+    const { clashes } = validationReport
+
+    const { edgeCount, a, b, edgeProps } = clashes
+    const { magnitude } = edgeProps
+    const { sizeFactor } = props
+
+    if (!edgeCount) return Mesh.createEmpty(mesh)
+
+    const pos = unit.conformation.invariantPosition
+
+    const builderProps = {
+        linkCount: edgeCount * 2,
+        position: (posA: Vec3, posB: Vec3, edgeIndex: number) => {
+            pos(a[edgeIndex], posA)
+            pos(b[edgeIndex], posB)
+        },
+        style: (edgeIndex: number) => LinkCylinderStyle.Disk,
+        radius: (edgeIndex: number) => magnitude[edgeIndex] * sizeFactor,
+        ignore: (edgeIndex: number) => {
+            return (
+                // TODO create lookup
+                !SortedArray.has(unit.elements, a[edgeIndex]) ||
+                !SortedArray.has(unit.elements, b[edgeIndex])
+            )
+        }
+    }
+
+    return createLinkCylinderMesh(ctx, builderProps, props, mesh)
+}
+
+export const IntraUnitClashParams = {
+    ...UnitsMeshParams,
+    ...LinkCylinderParams,
+    linkCap: PD.Boolean(true),
+    sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.01 }),
+}
+export type IntraUnitClashParams = typeof IntraUnitClashParams
+
+export function IntraUnitClashVisual(materialId: number): UnitsVisual<IntraUnitClashParams> {
+    return UnitsMeshVisual<IntraUnitClashParams>({
+        defaultProps: PD.getDefaultValues(IntraUnitClashParams),
+        createGeometry: createIntraUnitClashCylinderMesh,
+        createLocationIterator: createIntraClashIterator,
+        getLoci: getIntraClashLoci,
+        eachLocation: eachIntraClash,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<IntraUnitClashParams>, currentProps: PD.Values<IntraUnitClashParams>) => {
+            state.createGeometry = (
+                newProps.sizeFactor !== currentProps.sizeFactor ||
+                newProps.radialSegments !== currentProps.radialSegments ||
+                newProps.linkScale !== currentProps.linkScale ||
+                newProps.linkSpacing !== currentProps.linkSpacing ||
+                newProps.linkCap !== currentProps.linkCap
+            )
+        }
+    }, materialId)
+}
+
+function getIntraClashLoci(pickingId: PickingId, structureGroup: StructureGroup, id: number) {
+    const { objectId, instanceId, groupId } = pickingId
+    if (id === objectId) {
+        const { structure, group } = structureGroup
+        const unit = group.units[instanceId]
+        if (Unit.isAtomic(unit)) {
+            structure
+            groupId
+            // TODO
+        }
+    }
+    return EmptyLoci
+}
+
+function eachIntraClash(loci: Loci, structureGroup: StructureGroup, apply: (interval: Interval) => boolean) {
+    let changed = false
+    // TODO
+    return changed
+}
+
+function createIntraClashIterator(structureGroup: StructureGroup): LocationIterator {
+    const { group } = structureGroup
+    const unit = group.units[0]
+    const validationReport = ValidationReportProvider.get(unit.model).value!
+    const { clashes } = validationReport
+    const groupCount = clashes.edgeCount * 2
+    const instanceCount = group.units.length
+    const location = DataLocation(validationReport, 'clashes')
+    const getLocation = (groupIndex: number, instanceIndex: number) => {
+        location.index = groupIndex + instanceIndex * groupCount
+        return location
+    }
+    return LocationIterator(groupCount, instanceCount, getLocation)
+}
+
+//
+
+type InterUnitClashesProps = { id: number, magnitude: number, distance: number }
+
+function createInterUnitClashes(structure: Structure, clashes: ValidationReport['clashes']) {
+    const builder = new InterUnitGraph.Builder<Unit.Atomic, UnitIndex, InterUnitClashesProps>()
+    const { a, b, edgeProps: { id, magnitude, distance } } = clashes
+
+    Structure.eachUnitPair(structure, (unitA: Unit, unitB: Unit) => {
+        const elementsA = unitA.elements
+        const elementsB = unitB.elements
+
+        builder.startUnitPair(unitA as Unit.Atomic, unitB as Unit.Atomic)
+
+        for (let i = 0, il = clashes.edgeCount * 2; i < il; ++i) {
+            // TODO create lookup
+            let indexA = SortedArray.indexOf(elementsA, a[i])
+            let indexB = SortedArray.indexOf(elementsB, b[i])
+
+            if (indexA !== -1 && indexB !== -1) {
+                builder.add(indexA as UnitIndex, indexB as UnitIndex, {
+                    id: id[i],
+                    magnitude: magnitude[i],
+                    distance: distance[i]
+                })
+            }
+        }
+
+        builder.finishUnitPair()
+    }, {
+        maxRadius: arrayMax(clashes.edgeProps.distance),
+        validUnit: (unit: Unit) => Unit.isAtomic(unit),
+        validUnitPair: (unitA: Unit, unitB: Unit) => unitA.model === unitB.model
+    })
+
+    return new InterUnitGraph(builder.getMap())
+}
+
+function createInterUnitClashCylinderMesh(ctx: VisualContext, structure: Structure, theme: Theme, props: PD.Values<InterUnitClashParams>, mesh?: Mesh) {
+    const validationReport = ValidationReportProvider.get(structure.models[0]).value!
+    const clashes = createInterUnitClashes(structure, validationReport.clashes)
+
+    const { edges, edgeCount } = clashes
+    const { sizeFactor } = props
+
+    if (!edgeCount) return Mesh.createEmpty(mesh)
+
+    const builderProps = {
+        linkCount: edgeCount,
+        position: (posA: Vec3, posB: Vec3, edgeIndex: number) => {
+            const b = edges[edgeIndex]
+            const uA = b.unitA, uB = b.unitB
+            uA.conformation.position(uA.elements[b.indexA], posA)
+            uB.conformation.position(uB.elements[b.indexB], posB)
+        },
+        style: (edgeIndex: number) => LinkCylinderStyle.Disk,
+        radius: (edgeIndex: number) => edges[edgeIndex].props.magnitude * sizeFactor
+    }
+
+    return createLinkCylinderMesh(ctx, builderProps, props, mesh)
+}
+
+export const InterUnitClashParams = {
+    ...ComplexMeshParams,
+    ...LinkCylinderParams,
+    linkCap: PD.Boolean(true),
+    sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.01 }),
+}
+export type InterUnitClashParams = typeof InterUnitClashParams
+
+export function InterUnitClashVisual(materialId: number): ComplexVisual<InterUnitClashParams> {
+    return ComplexMeshVisual<InterUnitClashParams>({
+        defaultProps: PD.getDefaultValues(InterUnitClashParams),
+        createGeometry: createInterUnitClashCylinderMesh,
+        createLocationIterator: createInterClashIterator,
+        getLoci: getInterClashLoci,
+        eachLocation: eachInterClash,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<InterUnitClashParams>, currentProps: PD.Values<InterUnitClashParams>) => {
+            state.createGeometry = (
+                newProps.sizeFactor !== currentProps.sizeFactor ||
+                newProps.radialSegments !== currentProps.radialSegments ||
+                newProps.linkScale !== currentProps.linkScale ||
+                newProps.linkSpacing !== currentProps.linkSpacing ||
+                newProps.linkCap !== currentProps.linkCap
+            )
+        }
+    }, materialId)
+}
+
+function getInterClashLoci(pickingId: PickingId, structure: Structure, id: number) {
+    const { objectId, groupId } = pickingId
+    if (id === objectId) {
+        structure
+        groupId
+        // TODO
+    }
+    return EmptyLoci
+}
+
+function eachInterClash(loci: Loci, structure: Structure, apply: (interval: Interval) => boolean) {
+    let changed = false
+    // TODO
+    return changed
+}
+
+function createInterClashIterator(structure: Structure): LocationIterator {
+    const location = DataLocation({}, 'clashes')
+    return LocationIterator(1, 1, () => location)
+}
+
+//
+
+const ClashesVisuals = {
+    'intra-clash': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, IntraUnitClashParams>) => UnitsRepresentation('Intra-unit clash cylinder', ctx, getParams, IntraUnitClashVisual),
+    'inter-clash': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, InterUnitClashParams>) => ComplexRepresentation('Inter-unit clash cylinder', ctx, getParams, InterUnitClashVisual),
+}
+
+export const ClashesParams = {
+    ...IntraUnitClashParams,
+    ...InterUnitClashParams,
+    unitKinds: PD.MultiSelect<UnitKind>(['atomic'], UnitKindOptions),
+    visuals: PD.MultiSelect(['intra-clash', 'inter-clash'], PD.objectToOptions(ClashesVisuals))
+}
+export type ClashesParams = typeof ClashesParams
+export function getClashesParams(ctx: ThemeRegistryContext, structure: Structure) {
+    return PD.clone(ClashesParams)
+}
+
+export type ClashesRepresentation = StructureRepresentation<ClashesParams>
+export function ClashesRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, ClashesParams>): ClashesRepresentation {
+    return Representation.createMulti('Clashes', ctx, getParams, StructureRepresentationStateBuilder, ClashesVisuals as unknown as Representation.Def<Structure, ClashesParams>)
+}
+
+export const ClashesRepresentationProvider: StructureRepresentationProvider<ClashesParams> = {
+    label: 'RCSB Clashes',
+    description: 'Displays clashes between atoms as disks.',
+    factory: ClashesRepresentation,
+    getParams: getClashesParams,
+    defaultValues: PD.getDefaultValues(ClashesParams),
+    defaultColorTheme: ValidationReport.Tag.Clashes,
+    defaultSizeTheme: 'physical',
+    isApplicable: (structure: Structure) => structure.elementCount > 0,
+    ensureCustomProperties: (ctx: CustomProperty.Context, structure: Structure) => {
+        return ValidationReportProvider.attach(ctx, structure.models[0])
+    }
+}
+
+//
+
+function ClashesColorTheme(ctx: ThemeDataContext, props: {}): ColorTheme<{}> {
+    return {
+        factory: ClashesColorTheme,
+        granularity: 'uniform',
+        color: () => ColorNames.hotpink,
+        props,
+        description: 'Uniform color for clashes',
+    }
+}
+
+export const ClashesColorThemeProvider: ColorTheme.Provider<{}> = {
+    label: 'RCSB Clashes',
+    factory: ClashesColorTheme,
+    getParams: () => ({}),
+    defaultValues: PD.getDefaultValues({}),
+    isApplicable: (ctx: ThemeDataContext) => false,
+}

+ 74 - 0
src/mol-model-props/rcsb/themes/density-fit.ts

@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ThemeDataContext } from '../../../mol-theme/theme';
+import { ColorTheme, LocationColor } from '../../../mol-theme/color';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition'
+import { Color, ColorScale } from '../../../mol-util/color';
+import { StructureElement } from '../../../mol-model/structure';
+import { Location } from '../../../mol-model/location';
+import { CustomProperty } from '../../common/custom-property';
+import { ValidationReportProvider, ValidationReport } from '../validation-report';
+
+const DefaultColor = Color(0xCCCCCC)
+
+export function DensityFitColorTheme(ctx: ThemeDataContext, props: {}): ColorTheme<{}> {
+    let color: LocationColor = () => DefaultColor
+    const scaleRsrz = ColorScale.create({
+        minLabel: 'Poor',
+        maxLabel: 'Better',
+        domain: [2, 0],
+        listOrName: 'red-yellow-blue',
+    })
+    const scaleRscc = ColorScale.create({
+        minLabel: 'Poor',
+        maxLabel: 'Better',
+        domain: [0.678, 1.0],
+        listOrName: 'red-yellow-blue',
+    })
+
+    const validationReport = ctx.structure && ValidationReportProvider.get(ctx.structure.models[0])
+    const contextHash = validationReport?.version
+
+    const rsrz = validationReport?.value?.rsrz
+    const rscc = validationReport?.value?.rscc
+    const model = ctx.structure?.models[0]
+
+    if (rsrz && rscc && model) {
+        const residueIndex = model.atomicHierarchy.residueAtomSegments.index
+        color = (location: Location): Color => {
+            if (StructureElement.Location.is(location) && location.unit.model === model) {
+                const rsrzValue = rsrz.get(residueIndex[location.element])
+                if (rsrzValue !== undefined) return scaleRsrz.color(rsrzValue)
+                const rsccValue = rscc.get(residueIndex[location.element])
+                if (rsccValue !== undefined) return scaleRscc.color(rsccValue)
+                return DefaultColor
+            }
+            return DefaultColor
+        }
+    }
+
+    return {
+        factory: DensityFitColorTheme,
+        granularity: 'group',
+        color,
+        props,
+        contextHash,
+        description: 'Assigns residue colors according to the density fit using normalized Real Space R (RSRZ) for polymer residues and real space correlation coefficient (RSCC) for ligands. Colors range from poor (RSRZ = 2 or RSCC = 0.678) - to better (RSRZ = 0 or RSCC = 1.0).',
+        legend: scaleRsrz.legend
+    }
+}
+
+export const DensityFitColorThemeProvider: ColorTheme.Provider<{}> = {
+    label: 'RCSB Density Fit',
+    factory: DensityFitColorTheme,
+    getParams: () => ({}),
+    defaultValues: PD.getDefaultValues({}),
+    isApplicable: (ctx: ThemeDataContext) => ValidationReport.isApplicable(ctx.structure?.models[0]),
+    ensureCustomProperties: (ctx: CustomProperty.Context, data: ThemeDataContext) => {
+        return data.structure ? ValidationReportProvider.attach(ctx, data.structure.models[0]) : Promise.resolve()
+    }
+}

+ 89 - 0
src/mol-model-props/rcsb/themes/geometry-quality.ts

@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ThemeDataContext } from '../../../mol-theme/theme';
+import { ColorTheme, LocationColor } from '../../../mol-theme/color';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition'
+import { Color } from '../../../mol-util/color';
+import { StructureElement } from '../../../mol-model/structure';
+import { Location } from '../../../mol-model/location';
+import { CustomProperty } from '../../common/custom-property';
+import { ValidationReportProvider, ValidationReport } from '../validation-report';
+import { TableLegend } from '../../../mol-util/legend';
+import { PolymerType } from '../../../mol-model/structure/model/types';
+
+const DefaultColor = Color(0x909090)
+
+const NoIssuesColor = Color(0x2166ac)
+const OneIssueColor = Color(0xfee08b)
+const TwoIssuesColor = Color(0xf46d43)
+const ThreeOrMoreIssuesColor = Color(0xa50026)
+
+const ColorLegend = TableLegend([
+    ['No issues', NoIssuesColor],
+    ['One issue', OneIssueColor],
+    ['Two issues', TwoIssuesColor],
+    ['Three or more issues', ThreeOrMoreIssuesColor]
+])
+
+export function GeometryQualityColorTheme(ctx: ThemeDataContext, props: {}): ColorTheme<{}> {
+    let color: LocationColor = () => DefaultColor
+
+    const validationReport = ctx.structure && ValidationReportProvider.get(ctx.structure.models[0])
+    const contextHash = validationReport?.version
+
+    const value = validationReport?.value
+    const model = ctx.structure?.models[0]
+
+    if (value && model) {
+        const { geometryIssues, clashes, bondOutliers, angleOutliers } = value
+        const residueIndex = model.atomicHierarchy.residueAtomSegments.index
+        const { polymerType } = model.atomicHierarchy.derived.residue
+        color = (location: Location): Color => {
+            if (StructureElement.Location.is(location) && location.unit.model === model) {
+                const { element } = location
+                const rI = residueIndex[element]
+                let value = geometryIssues.get(rI)?.size
+                if (value !== undefined && polymerType[rI] === PolymerType.NA) {
+                    value = 0
+                    if (clashes.getVertexEdgeCount(element) > 0) value += 1
+                    if (bondOutliers.index.has(element)) value += 1
+                    if (angleOutliers.index.has(element)) value += 1
+                }
+
+                switch (value) {
+                    case undefined: return DefaultColor
+                    case 0: return NoIssuesColor
+                    case 1: return OneIssueColor
+                    case 2: return TwoIssuesColor
+                    default: return ThreeOrMoreIssuesColor
+                }
+            }
+            return DefaultColor
+        }
+    }
+
+    return {
+        factory: GeometryQualityColorTheme,
+        granularity: 'group',
+        color,
+        props,
+        contextHash,
+        description: 'Assigns residue colors according to the number of geometry issues.',
+        legend: ColorLegend
+    }
+}
+
+export const GeometryQualityColorThemeProvider: ColorTheme.Provider<{}> = {
+    label: 'RCSB Geometry Quality',
+    factory: GeometryQualityColorTheme,
+    getParams: () => ({}),
+    defaultValues: PD.getDefaultValues({}),
+    isApplicable: (ctx: ThemeDataContext) => ValidationReport.isApplicable(ctx.structure?.models[0]),
+    ensureCustomProperties: (ctx: CustomProperty.Context, data: ThemeDataContext) => {
+        return data.structure ? ValidationReportProvider.attach(ctx, data.structure.models[0]) : Promise.resolve()
+    }
+}

+ 63 - 0
src/mol-model-props/rcsb/themes/random-coil-index.ts

@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ThemeDataContext } from '../../../mol-theme/theme';
+import { ColorTheme, LocationColor } from '../../../mol-theme/color';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition'
+import { Color, ColorScale } from '../../../mol-util/color';
+import { StructureElement } from '../../../mol-model/structure';
+import { Location } from '../../../mol-model/location';
+import { CustomProperty } from '../../common/custom-property';
+import { ValidationReportProvider, ValidationReport } from '../validation-report';
+
+const DefaultColor = Color(0xCCCCCC)
+
+export function RandomCoilIndexColorTheme(ctx: ThemeDataContext, props: {}): ColorTheme<{}> {
+    let color: LocationColor = () => DefaultColor
+    const scale = ColorScale.create({
+        reverse: true,
+        domain: [0, 0.6],
+        listOrName: 'red-yellow-blue',
+    })
+
+    const validationReport = ctx.structure && ValidationReportProvider.get(ctx.structure.models[0])
+    const contextHash = validationReport?.version
+
+    const rci = validationReport?.value?.rci
+    const model = ctx.structure?.models[0]
+
+    if (rci && model) {
+        const residueIndex = model.atomicHierarchy.residueAtomSegments.index
+        color = (location: Location): Color => {
+            if (StructureElement.Location.is(location) && location.unit.model === model) {
+                const value = rci.get(residueIndex[location.element])
+                return value === undefined ? DefaultColor : scale.color(value)
+            }
+            return DefaultColor
+        }
+    }
+
+    return {
+        factory: RandomCoilIndexColorTheme,
+        granularity: 'group',
+        color,
+        props,
+        contextHash,
+        description: 'Assigns residue colors according to the Random Coil Index value.',
+        legend: scale.legend
+    }
+}
+
+export const RandomCoilIndexColorThemeProvider: ColorTheme.Provider<{}> = {
+    label: 'RCSB Random Coil Index',
+    factory: RandomCoilIndexColorTheme,
+    getParams: () => ({}),
+    defaultValues: PD.getDefaultValues({}),
+    isApplicable: (ctx: ThemeDataContext) => ValidationReport.isApplicable(ctx.structure?.models[0]),
+    ensureCustomProperties: (ctx: CustomProperty.Context, data: ThemeDataContext) => {
+        return data.structure ? ValidationReportProvider.attach(ctx, data.structure.models[0]) : Promise.resolve()
+    }
+}

+ 348 - 0
src/mol-model-props/rcsb/validation-report.ts

@@ -0,0 +1,348 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../mol-util/param-definition'
+import { CustomPropertyDescriptor } from '../../mol-model/structure';
+// import { Database as _Database } from '../../mol-data/db'
+import { CustomProperty } from '../common/custom-property';
+import { CustomModelProperty } from '../common/custom-model-property';
+import { Model, ElementIndex, ResidueIndex } from '../../mol-model/structure/model';
+import { IntAdjacencyGraph } from '../../mol-math/graph';
+import { readFromFile } from '../../mol-util/data-source';
+
+export { ValidationReport }
+
+interface ValidationReport {
+    /**
+     * Real Space R (RSRZ) for residues,
+     * defined for polymer residues in X-ray structures
+     */
+    rsrz: Map<ResidueIndex, number>
+    /**
+     * Real Space Correlation Coefficient (RSCC) for residues,
+     * defined for each non-polymer residue in X-ray structures
+     */
+    rscc: Map<ResidueIndex, number>
+    /**
+     * Random Coil Index (RCI) for residues,
+     * defined for polymer residues in NMR structures
+     */
+    rci: Map<ResidueIndex, number>
+    /**
+     * Set of geometry issues for residues
+     */
+    geometryIssues: Map<ResidueIndex, Set<string>>
+
+    /**
+     * Set of bond outliers
+     */
+    bondOutliers: {
+        index: Map<ElementIndex, number>
+        data: {
+            atomA: ElementIndex, atomB: ElementIndex
+            zScore: number, mean: number, mindiff: number,
+            numobs: number, obsval: number, stdev: number
+        }[]
+    }
+    /**
+     * Set of angle outliers
+     */
+    angleOutliers: {
+        index: Map<ElementIndex, number>
+        data: {
+            atomA: ElementIndex, atomB: ElementIndex, atomC: ElementIndex,
+            zScore: number, mean: number, mindiff: number,
+            numobs: number, obsval: number, stdev: number
+        }[]
+    }
+
+    /**
+     * Clashes between atoms, including id, magniture and distance
+     */
+    clashes: IntAdjacencyGraph<ElementIndex, {
+        readonly id: ArrayLike<number>
+        readonly magnitude: ArrayLike<number>
+        readonly distance: ArrayLike<number>
+    }>
+}
+
+namespace ValidationReport {
+    export enum Tag {
+        DensityFit = 'rcsb-density-fit',
+        GeometryQuality = 'rcsb-geometry-quality',
+        RandomCoilIndex = 'rcsb-random-coil-index',
+        Clashes = 'rcsb-clashes',
+    }
+
+    export const DefaultBaseUrl = '//ftp.rcsb.org/pub/pdb/validation_reports'
+    export function getEntryUrl(pdbId: string, baseUrl: string) {
+        const id = pdbId.toLowerCase()
+        return `${baseUrl}/${id.substr(1, 2)}/${id}/${id}_validation.xml.gz`
+    }
+
+    export function isApplicable(model?: Model): boolean {
+        return (
+            !!model &&
+            model.sourceData.kind === 'mmCIF' &&
+            (model.sourceData.data.database_2.database_id.isDefined ||
+                model.entryId.length === 4)
+        )
+    }
+
+    export function fromXml(xml: XMLDocument, model: Model): ValidationReport {
+        return parseValidationReportXml(xml, model)
+    }
+
+    export async function fetch(ctx: CustomProperty.Context, model: Model, props: ServerSourceProps): Promise<ValidationReport> {
+        const url = getEntryUrl(model.entryId, props.baseUrl)
+        const xml = await ctx.fetch({ url, type: 'xml' }).runInContext(ctx.runtime)
+        return fromXml(xml, model)
+    }
+
+    export async function open(ctx: CustomProperty.Context, model: Model, props: FileSourceProps): Promise<ValidationReport> {
+        const xml = await readFromFile(props.input, 'xml').runInContext(ctx.runtime)
+        return fromXml(xml, model)
+    }
+
+    export async function obtain(ctx: CustomProperty.Context, model: Model, props: ValidationReportProps): Promise<ValidationReport> {
+        switch(props.source.name) {
+            case 'file': return open(ctx, model, props.source.params)
+            case 'server': return fetch(ctx, model, props.source.params)
+        }
+    }
+}
+
+const FileSourceParams = {
+    input: PD.File({ accept: '.xml,.gz,.zip' })
+}
+type FileSourceProps = PD.Values<typeof FileSourceParams>
+
+const ServerSourceParams = {
+    baseUrl: PD.Text(ValidationReport.DefaultBaseUrl, { description: 'Base URL to directory tree' })
+}
+type ServerSourceProps = PD.Values<typeof ServerSourceParams>
+
+export const ValidationReportParams = {
+    source: PD.MappedStatic('server', {
+        'file': PD.Group(FileSourceParams, { label: 'File', isFlat: true }),
+        'server': PD.Group(ServerSourceParams, { label: 'Server', isFlat: true }),
+    }, { options: [['file', 'File'], ['server', 'Server']] })
+}
+export type ValidationReportParams = typeof ValidationReportParams
+export type ValidationReportProps = PD.Values<ValidationReportParams>
+
+export const ValidationReportProvider: CustomModelProperty.Provider<ValidationReportParams, ValidationReport> = CustomModelProperty.createProvider({
+    label: 'RCSB Validation Report',
+    descriptor: CustomPropertyDescriptor({
+        name: 'rcsb_validation_report',
+        // TODO `cifExport` and `symbol`
+    }),
+    type: 'dynamic',
+    defaultParams: ValidationReportParams,
+    getParams: (data: Model) => ValidationReportParams,
+    isApplicable: (data: Model) => ValidationReport.isApplicable(data),
+    obtain: async (ctx: CustomProperty.Context, data: Model, props: Partial<ValidationReportProps>) => {
+        const p = { ...PD.getDefaultValues(ValidationReportParams), ...props }
+        return await ValidationReport.obtain(ctx, data, p)
+    }
+})
+
+//
+
+function getItem(a: NamedNodeMap, name: string) {
+    const item = a.getNamedItem(name)
+    return item !== null ? item.value : ''
+}
+
+function hasAttr(a: NamedNodeMap, name: string, value: string) {
+    const item = a.getNamedItem(name)
+    return item !== null && item.value === value
+}
+
+function getMogInfo(a: NamedNodeMap) {
+    return {
+        zScore: parseFloat(getItem(a, 'Zscore')),
+        mean: parseFloat(getItem(a, 'mean')),
+        mindiff: parseFloat(getItem(a, 'mindiff')),
+        numobs: parseInt(getItem(a, 'numobs')),
+        obsval: parseFloat(getItem(a, 'obsval')),
+        stdev: parseFloat(getItem(a, 'stdev')),
+    }
+}
+
+function ClashesBuilder(elementsCount: number) {
+    const aIndices: ElementIndex[] = []
+    const bIndices: ElementIndex[] = []
+    const ids: number[] = []
+    const magnitudes: number[] = []
+    const distances: number[] = []
+
+    const seen = new Map<number, ElementIndex>()
+
+    return {
+        add(element: ElementIndex, id: number, magnitude: number, distance: number) {
+            const other = seen.get(id)
+            if (other !== undefined) {
+                aIndices[aIndices.length] = element
+                bIndices[bIndices.length] = other
+                ids[ids.length] = id
+                magnitudes[magnitudes.length] = magnitude
+                distances[distances.length] = distance
+            } else {
+                seen.set(id, element)
+            }
+        },
+        get() {
+            const builder = new IntAdjacencyGraph.EdgeBuilder(elementsCount, aIndices, bIndices)
+            const id = new Int32Array(builder.slotCount)
+            const magnitude = new Float32Array(builder.slotCount)
+            const distance = new Float32Array(builder.slotCount)
+            for (let i = 0, _i = builder.edgeCount; i < _i; i++) {
+                builder.addNextEdge()
+                builder.assignProperty(id, ids[i])
+                builder.assignProperty(magnitude, magnitudes[i])
+                builder.assignProperty(distance, distances[i])
+            }
+            return builder.createGraph({ id, magnitude, distance })
+        }
+    }
+}
+
+function parseValidationReportXml(xml: XMLDocument, model: Model): ValidationReport {
+    const rsrz = new Map<ResidueIndex, number>()
+    const rscc = new Map<ResidueIndex, number>()
+    const rci = new Map<ResidueIndex, number>()
+    const geometryIssues = new Map<ResidueIndex, Set<string>>()
+
+    const bondOutliers = {
+        index: new Map<ElementIndex, number>(),
+        data: [] as ValidationReport['bondOutliers']['data']
+    }
+    const angleOutliers = {
+        index: new Map<ElementIndex, number>(),
+        data: [] as ValidationReport['angleOutliers']['data']
+    }
+
+    const clashesBuilder = ClashesBuilder(model.atomicHierarchy.atoms._rowCount)
+
+    const { index } = model.atomicHierarchy
+
+    const entries = xml.getElementsByTagName('Entry')
+    if (entries.length === 1) {
+        const chemicalShiftLists = entries[0].getElementsByTagName('chemical_shift_list')
+        if (chemicalShiftLists.length === 1) {
+            const randomCoilIndices = chemicalShiftLists[0].getElementsByTagName('random_coil_index')
+            for (let j = 0, jl = randomCoilIndices.length; j < jl; ++j) {
+                const { attributes } = randomCoilIndices[j]
+                const value = parseFloat(getItem(attributes, 'value'))
+                const auth_asym_id = getItem(attributes, 'chain')
+                const auth_comp_id = getItem(attributes, 'rescode')
+                const auth_seq_id = parseInt(getItem(attributes, 'resnum'))
+                const rI = index.findResidueAuth({ auth_asym_id, auth_comp_id, auth_seq_id })
+                if (rI !== -1) rci.set(rI, value)
+            }
+        }
+    }
+
+    const groups = xml.getElementsByTagName('ModelledSubgroup')
+    for (let i = 0, il = groups.length; i < il; ++i) {
+        const g = groups[ i ]
+        const ga = g.attributes
+
+        const pdbx_PDB_model_num = parseInt(getItem(ga, 'model'))
+        if (model.modelNum !== pdbx_PDB_model_num) continue
+
+        const auth_asym_id = getItem(ga, 'chain')
+        const auth_comp_id = getItem(ga, 'resname')
+        const auth_seq_id = parseInt(getItem(ga, 'resnum'))
+        const pdbx_PDB_ins_code = getItem(ga, 'icode').trim() || undefined
+        const label_alt_id = getItem(ga, 'altcode').trim() || undefined
+
+        const rI = index.findResidueAuth({ auth_asym_id, auth_comp_id, auth_seq_id, pdbx_PDB_ins_code })
+
+        // continue if no residue index is found
+        if (rI === -1) continue
+
+        if (ga.getNamedItem('rsrz') !== null) rsrz.set(rI, parseFloat(getItem(ga, 'rsrz')))
+        if (ga.getNamedItem('rscc') !== null) rscc.set(rI, parseFloat(getItem(ga, 'rscc')))
+
+        const isPolymer = getItem(ga, 'seq') !== '.'
+        const issues = new Set<string>()
+
+        if (isPolymer) {
+            const angleOutliers = g.getElementsByTagName('angle-outlier')
+            if (angleOutliers.length) issues.add('angle-outlier')
+
+            const bondOutliers = g.getElementsByTagName('bond-outlier')
+            if (bondOutliers.length) issues.add('bond-outlier')
+
+            const planeOutliers = g.getElementsByTagName('plane-outlier')
+            if (planeOutliers.length) issues.add('plane-outlier')
+
+            if (hasAttr(ga, 'rota', 'OUTLIER')) issues.add('rotamer-outlier')
+            if (hasAttr(ga, 'rama', 'OUTLIER')) issues.add('ramachandran-outlier')
+            if (hasAttr(ga, 'RNApucker', 'outlier')) issues.add('RNApucker-outlier')
+        } else {
+            const mogBondOutliers = g.getElementsByTagName('mog-bond-outlier')
+            if (mogBondOutliers.length) issues.add('mogul-bond-outlier')
+
+            const mogAngleOutliers = g.getElementsByTagName('mog-angle-outlier')
+            if (mogAngleOutliers.length) issues.add('mogul-angle-outlier')
+
+            for (let j = 0, jl = mogBondOutliers.length; j < jl; ++j) {
+                const mbo = mogBondOutliers[ j ].attributes
+                const atoms = getItem(mbo, 'atoms').split(',')
+                const idx = bondOutliers.data.length
+                const atomA = index.findAtomOnResidue(rI, atoms[0])
+                const atomB = index.findAtomOnResidue(rI, atoms[1])
+                bondOutliers.index.set(atomA, idx)
+                bondOutliers.index.set(atomB, idx)
+                bondOutliers.data.push({ atomA, atomB, ...getMogInfo(mbo) })
+            }
+
+            for (let j = 0, jl = mogAngleOutliers.length; j < jl; ++j) {
+                const mao = mogAngleOutliers[ j ].attributes
+                const atoms = getItem(mao, 'atoms').split(',')
+                const idx = angleOutliers.data.length
+                const atomA = index.findAtomOnResidue(rI, atoms[0])
+                const atomB = index.findAtomOnResidue(rI, atoms[1])
+                const atomC = index.findAtomOnResidue(rI, atoms[1])
+                angleOutliers.index.set(atomA, idx)
+                angleOutliers.index.set(atomB, idx)
+                angleOutliers.index.set(atomC, idx)
+                angleOutliers.data.push({ atomA, atomB, atomC, ...getMogInfo(mao) })
+            }
+        }
+
+        const clashes = g.getElementsByTagName('clash')
+        if (clashes.length) issues.add('clash')
+
+        for (let j = 0, jl = clashes.length; j < jl; ++j) {
+            const ca = clashes[ j ].attributes
+            const id = parseInt(getItem(ca, 'cid'))
+            const magnitude = parseFloat(getItem(ca, 'clashmag'))
+            const distance = parseFloat(getItem(ca, 'dist'))
+            const label_atom_id = getItem(ca, 'atom')
+            const element = index.findAtomOnResidue(rI, label_atom_id, label_alt_id)
+            if (element !== -1) {
+                clashesBuilder.add(element, id, magnitude, distance)
+            }
+        }
+
+        geometryIssues.set(rI, issues)
+    }
+
+    const clashes = clashesBuilder.get()
+
+    const validationReport = {
+        rsrz, rscc, rci, geometryIssues,
+        bondOutliers, angleOutliers,
+        clashes
+    }
+    console.log(validationReport)
+
+    return validationReport
+}

+ 77 - 0
src/mol-plugin/behavior/dynamic/custom-props/rcsb/validation-report.ts

@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../../../mol-util/param-definition'
+import { PluginBehavior } from '../../../behavior';
+import { ValidationReport, ValidationReportProvider } from '../../../../../mol-model-props/rcsb/validation-report';
+import { RandomCoilIndexColorThemeProvider } from '../../../../../mol-model-props/rcsb/themes/random-coil-index';
+import { GeometryQualityColorThemeProvider } from '../../../../../mol-model-props/rcsb/themes/geometry-quality';
+import { Loci } from '../../../../../mol-model/loci';
+import { OrderedSet } from '../../../../../mol-data/int';
+import { ClashesRepresentationProvider, ClashesColorThemeProvider } from '../../../../../mol-model-props/rcsb/representations/validation-report-clashes';
+import { DensityFitColorThemeProvider } from '../../../../../mol-model-props/rcsb/themes/density-fit';
+
+const Tag = ValidationReport.Tag
+
+export const RCSBValidationReport = PluginBehavior.create<{ autoAttach: boolean }>({
+    name: 'rcsb-validation-report-prop',
+    category: 'custom-props',
+    display: { name: 'RCSB Validation Report' },
+    ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean }> {
+        private provider = ValidationReportProvider
+
+        register(): void {
+            this.ctx.customModelProperties.register(this.provider, this.params.autoAttach);
+            this.ctx.lociLabels.addProvider(geometryQualityLabelProvider);
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.add(Tag.DensityFit, DensityFitColorThemeProvider)
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.add(Tag.GeometryQuality, GeometryQualityColorThemeProvider)
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.add(Tag.RandomCoilIndex, RandomCoilIndexColorThemeProvider)
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.add(Tag.Clashes, ClashesColorThemeProvider)
+            this.ctx.structureRepresentation.registry.add(Tag.Clashes, ClashesRepresentationProvider)
+        }
+
+        update(p: { autoAttach: boolean }) {
+            let updated = this.params.autoAttach !== p.autoAttach
+            this.params.autoAttach = p.autoAttach;
+            this.ctx.customStructureProperties.setDefaultAutoAttach(this.provider.descriptor.name, this.params.autoAttach);
+            return updated;
+        }
+
+        unregister() {
+            this.ctx.customStructureProperties.unregister(this.provider.descriptor.name);
+            this.ctx.lociLabels.removeProvider(geometryQualityLabelProvider);
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.remove(Tag.DensityFit)
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.remove(Tag.GeometryQuality)
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.remove(Tag.RandomCoilIndex)
+            this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.remove(Tag.Clashes)
+            this.ctx.structureRepresentation.registry.remove(Tag.Clashes)
+        }
+    },
+    params: () => ({
+        autoAttach: PD.Boolean(false),
+        baseUrl: PD.Text(ValidationReport.DefaultBaseUrl)
+    })
+});
+
+function geometryQualityLabelProvider(loci: Loci): string | undefined {
+    switch (loci.kind) {
+        case 'element-loci':
+            if (loci.elements.length === 0) return void 0;
+            const e = loci.elements[0];
+            const geometryIssues = ValidationReportProvider.get(e.unit.model).value?.geometryIssues
+            if (!geometryIssues) return
+
+            const residueIndex = e.unit.model.atomicHierarchy.residueAtomSegments.index
+            const issues = geometryIssues.get(residueIndex[e.unit.elements[OrderedSet.start(e.indices)]])
+            if (!issues || issues.size === 0) return 'RCSB Geometry Quality: no issues';
+
+            const label: string[] = []
+            issues.forEach(i => label.push(i))
+            return `RCSB Geometry Quality: ${label.join(', ')}`;
+
+        default: return;
+    }
+}