Wednesday, June 9, 2021

The Interop needle in 2030


ONC has been asking about Health Interoperability Outcomes for 2030.  Some sample statements they'd like the answers to include:

  • “Because of interoperability, ______ before/by 2030.”
  • “Because of interoperability, before/by 2030 [who] will [what].”

    In Why We'll Never Have Interoperability, I note that the goal posts are always moving, bars are ever being raised, the needle just keeps going around and around.  So I thought it would be interested to look at this from the viewpoint of "what will we be concerned about" in 2030 as the next steps, rather than focusing on what we've accomplished.

    Given all of that, these are the problems I think we will we still be trying to solve:

    1. Making challenging content understandable to the average patient.
    2. Making standards of care (e.g., care guidelines) measurable and computable.
    3. Understanding the actual cost of care.
    4. Crossing domain boundaries (care, payment, social services, public health, emergency medical services, research).  Each of these domains is still widely separated with respect to standards
    5. Few of these domains have progressed as far with APIs as simple as FHIR.
    6. Handling variations in dialects of FHIR as supported by different vendors.
    And these are the problems that I think we will have made headway on:
    1. FHIR will have become ubiquitous in hospital and ambulatory practice interfaces between healthcare systems and devices.  It will become available in not just EHR systems, but also departmental, laboratory, medical devices, revenue cycle, and to some degree, imaging systems (although DICOM will retain significant dominance).
    2. Patient facing APIs will be ubiquitous.
    3. Visit scheduling, and much of the pre-visit "paper-work" will be done via the web for most patients.
    4. FHIR will have crossed into the payer space, and HIPAA transaction standards, invading some of the territory previously owned by X12 and NCPDP.
    5. We will start to see FHIR transition into other healthcare related domains (e.g., EMS reporting, social services), but adoption will be limited.



    Monday, May 24, 2021

    A Source Code Profiler for FHIRPath

    I wrote my first source code profiler in 1992, mostly as an exercise to play with but also because back then I didn't have the money for a professional one, and wrote about it in Dr. Dobb's Journal.  That was a sampling profiler, and used the timer interrupt on a PC to determine where in the code the application was running.  Since then, I've had to write my own profiler several times professionally for different reasons and purposes.  Once I wrote some code to instrument some BASIC code and profile it.  That code was running a web site for a major hardware vendor's web store (having been flown onsite to address a critical sporadic outage).  While the profiler didn't show what was causing the outage, it did demonstrate that the application our company had developed wasn't the source of the problem, and I later DID determine what the cause was and we fixed it.  I was given my first handheld computing device as a reward for that save.  And I've been called to develop profiling tools for other custom languages and application implementations as well.

    The focus of this week's FHIR Connectathon for SANER was performance and scaling.  We finished up at noon yesterday, but I was already wire to the rim with caffeine, so I looked for something else interesting to do.  As a result, I instrumented my version of the FHIR Path engine (it's basically a copy of the HAPI engine with some tweaks for use as a SANER Measure Computer) to profile the Measure code.

    There are three places in SANER where a FHIRPath is evaluated to compute a measure:

    1. Measure.group.population which produces the counts for the population.
    2. Measure.group.stratifer which produces the strata for each element in the population.
    3. Measure.supplementalData which specifies how to collect supplemental data for each element in a population.
    Each of these may have an associated FHIRPath expression, and of course, I want to know how the expression performs.

    The FHIRPath engine produces a parse tree of ExpressionNode items.  The tree is walked by FHIRPathEngine.evaluate functions.  What I did was simply added some code to trace the execution times in those functions in my FHIRPath engine code.

    The core class and methods to support tracing in my engine is below.  Note, this is hacked Proof-of-Concept code I wrote in about an hour.  Documentation and everything else is coming.

       class Trace {
            String expr;
            long start, end, children;
            ExpressionNode node;
            int parent;
            int index;

            private Trace(String expr, int index) {
                this.expr = expr;
                this.index = index;
                this.start = System.nanoTime();
                this.parent = -1;
                Object o = new Object();
                o.toString();
            }

            private Trace(int parent, int index, ExpressionNode node) {
                this.parent = parent;
                this.expr = (parent == -1 ? null : traces.get(parent).expr);
                this.index = index;
                this.node = node;
                this.start = System.nanoTime();
            }

            public void done() {
                this.end = System.nanoTime();
                long childTime = end - start;
                top = this.parent;
                if (top != -1) {
                    traces.get(top).children += childTime;
                }
            }

            public void done(ExpressionNode node) {
                this.node = node;
                done();
            }

            public String toString() {
                StringBuilder b = new StringBuilder();
                if (parent == -1) {
                    b.append(",,,,,,,\"").append(expr == null ? " " : expr.toString().replace("\"", "\"\"")).append("\"\n");
                }
                b.append("@").append(Integer.toHexString(expr.hashCode())).append(",").append(index).append(",")
                    .append(parent).append(",").append(start).append(",").append(end).append(",")
                    .append(end - start - children).append(",\"").append(node.toString().replace("\"", "\"\""))
                    .append("\"");
                return b.toString();
            }

            public void setExpression(String expression) {
                this.expr = expression;
            }
        }

        public void dump(File f, boolean append) throws IOException {
            try (FileWriter w = new FileWriter(f, StandardCharsets.UTF_8, append); PrintWriter pw = new PrintWriter(w);) {
                pw.println("Node,Expr,Index,Parent,Start,End,Time,Expression");
                for (Trace t : traces) {
                    pw.println(t);
                }
            }
            traces.clear();
        }

        private Trace trace(String path) {
            if (!TRACE) {
                return dummy;
            }
            Trace trace = new Trace(path, traces.size());
            traces.add(trace);
            top = traces.size() - 1;
            return trace;
        }

        private Trace trace(ExpressionNode node) {
            if (!TRACE) {
                return dummy;
            }
            Trace trace = new Trace(top, traces.size(), node);
            traces.add(trace);
            top = traces.size() - 1;
            return trace;
        }

    Sprinkled in key places in functions that actually execute the profiling code are the statements (in a slightly larger font) in the code below:

        public List<Base> evaluate(Base base, ExpressionNode expressionNode) throws FHIRException {
            Trace trace = trace(expressionNode);
            try {
                List<Base> list = new ArrayList<Base>();
                if (base != null)
                    list.add(base);
                log = new StringBuilder();
                return execute(new ExecutionContext(null, base != null && base.isResource() ? base : null,
                    base != null && base.isResource() ? base : null, base, null, base), 
                    list, expressionNode, true);
            } finally {
                trace.done();
            }
        }

    What goes on is that the call to trace(expressionNode) creates a new timestamped execution record to a stack of records which also points to the record of the calling method [often at the top of the stack].  Then, when the function is finished, trace.done() simply adds the ending time stamp, accumulates the time of this call to an accumulator for child times in the parent record, and returns the stack pointer to the parent's record.

    When a user of the engine calls the dump() method with a file, the execution data is dumped to that file in CSV format.  Records look something like this:

    Expr         Index Parent Start         End         Time Expression
    @63efb167 1 -1 611441841022000 611451511487500 4711600 findAll('Encounter', ...
    @791ea926 4 3 611441845767800 611441845782600 13400 'Encounter'
    @791ea926 5 4 611441845771000 611441845772400 1400 'Encounter'
    @6344cac3 6 3 611441845783900 611441847333500 12700 including('subject', 'diagnosis', ...
    @6344cac3 7 6 611441845785400 611441847322300 1533300 including('subject', 'diagnosis', ...
    @27688320 8 7 611441845786100 611441845787600 1200 'subject'
    @27688320 9 8 611441845786600 611441845786900 300 'subject'
    @39126a23 10 7 611441845788000 611441845789200 1000 'diagnosis'
    @39126a23 11 10 611441845788600 611441845788800 200 'diagnosis'
    @35c9a3be 12 7 611441845789500 611441845790400 700 'reasonReference'
    @35c9a3be 13 12 611441845789900 611441845790100 200 'reasonReference'


    The expression identifies the unique expression from the parse tree being evaluated.  The index and parent describe the evaluation tree (which may evaluate the same expression multiple times with different inputs).  The start and end time reflect the timestamps (in nanoseconds from an arbitrary base).  Time represents the elapsed time (again in nanoseconds).  The expression gives the full text of the evaluated expression.

    You can take this data into a spreadsheet, create a pivot table report, and come up with useful reports on those parts of the expression taking the longest, as shown below.

    Expression   Time (ms)  Executions
    onServers(%Base).exists()         9145.7723         282
    onServers(%Base).where(...         405.8266           2
    $total | $this     47.0435          94
    %ReportingPeriod.start - 1 'year'   10.4663         423


    I normalized times to milliseconds so that the result would be more meaningful.  This shows that the retrieval of primary data takes about 9.1 seconds, and a secondary retrieval about 0.4 seeconds, while everything else is 50ms or less.

    It also demonstrates some opportunities for optimization in the FHIRPath engine.  For example the 10ms taken on the the multiple evaluations of the common sub-expression:
      %ReportingPeriod.start  - 1 'year' 

    This is likely more costly than it needs to be.  Since the FHIRPath engine today does nothing with common subexpression elimination, the profile shows the cost associated with re-evaluating these in the parse.  That's probably an opportunity for a whole other post.

        Keith


    Tuesday, May 18, 2021

    SANER Scales

    This week's HL7 Connectathon 27 SANER Track is all about scalability.  For this Connectathon, we brought over 2 million clinical resources worth of test data for 2600+ patients, transported from Synthea's 100K patient COVID-19 test data set from Massachusetts into Cook County, Illinois, transposed in time to January of 2021 instead of March of 2020, across 15 hospital locations.  Because these patients were transported from the past, they aren't vaccinated, don't have access to Remdesivir, and probably too many are taking HCQ, but it was a good enough set to test the scalability of SANER.

    My chief concern was computing MeasureReport on a daily basis, overnight, and whether that was going to be a huge data load for hospitals.  As it turns out, I'm able to put those concerns to rest (for me at least).

    We computed 465 MeasureReport resources, one for each of 15 hospitals over the 31 days of January, using realistic hospital loads drawn from current statistics reported by the Illinois Department of Health.

    Each measure report communicated around 240 (average) supplemental data elements (FHIR Resources) providing additional data to support stratification and analytics, which about 40 times what would actually be needed if just communicating metrics.

    All told, this represented about 465Mb of uncompressed, pretty printed FHIR Resources in XML format, or about 23Mb of data compressed using GZIP.

    Best, yet, I was able to collect data from the cloud, compute measures, store them locally and transmit all the data for all days for all hospitals to a server in the Cloud in about 11 minutes on a pretty high-end Dell Laptop (6 cores, 3.6Ghz Burst, 32Gb of RAM).

    I've still got some bugs to look into which might slow things down once fixed (mostly on stratification), but with 12 virtual processors running, this load barely touched my machine.  Overall, CPU utilization was at a pretty steady 20%, and network bandwidth also nowhere near saturated.  My home office gets about 150-200Mb down, 20Mb up, I barely touched it.

    I can process the data for a single hospital day in 10-20 seconds depending on the number of patients.  It's realistic to assume that more frequent, semi-real-time situational awareness measure evaluation and reporting is not only feasible, but also practical.

    Most of the measures we have examined are written in a form that supports daily computation.  We'll probably have to experiment with measures designed for more frequent evaluation.

       Keith

    * We keep hemming and hawing about near-real-time measures, and I've finally decided to call them semi-real-time, to clarify that they could be several minutes out of date, but still orders of magnitude better than daily.  With enough concentration, semi-real-time could in fact become near-real-time (so long as the data sources themselves are frequently updated).


    After doing some more tweaking I'm actually:
    1. Overwhelming my server so hard it requires a restart to come back to life.  I really need to get my server set up in a production ready way.
    2. Running a bit slower but getting more data (so now it's taking about 28 second a hospital on average).

    Tuesday, May 11, 2021

    Tracking Supplies via SANER

    One of the outstanding challenges yet to be demonstrated at a Connectathon is the ability of SANER to report on supplies.

    Some common examples include N95 Respirators (a.k.a. Surgical Masks), Ventilator supplies (e.g., tubing, connectors, et cetera), gloves, gowns and cleaning supplies.

    How would you track these materials in ways that would enable an inventory system to track them?  

    Honestly, this is an area that I've spent very little time with, but is one focus area I plan to study for the upcoming May HL7 Connectathon.

    What I already know:

    All of this knowledge is captured, registered, and publicly available.  Some possibly for purchase, some freely accessible, some even with an API.

    So, if you have UPC numbers or GMDN codes, or GUDID data, you might actually be able to create a value set that you can get to from the item codes used to describe supplies in the hospital inventory control systems.




    Monday, April 19, 2021

    Make Vaccine Scheduling "Scheduler Friendly"

    Every family I know has (at least) that one person who has to have the planning calendar, and that other person who keeps track of all the important documents, and that other person that they call on for healthcare related stuff, and finally, the computer geek.  And they may all reside in the same person.  One of these is very likely the COVID-19 vaccine scheduler.

    As I think about how vaccines are opening up, and my own experience in Massachusetts scheduling vaccines for my family, here are some of my experiences:

    1. I have to enter the same insurance data each time for each different person I'm making an appointment for.  If only there was a standard for the layout and OCR font for insurance cards, or better yet, even a standard bar-code or QR format for insurance information, it could have made my life so much easier.

    2. I could never schedule more than one person at the same time, even if there are two or three qualifying individuals that I need to schedule for at the same time (and appointments open).  This resulted in me making 2 or 3 different appointments for a two groups of people who each had to travel over 30 minutes to a total of 5 different locations during two different enrollment periods. In one case, I fat fingered the first appointment date, which meant I had to reschedule one of the appointments, which led to a three week delay in getting a replacement appointment.
    I've seen six different scheduling interfaces (four for drug-stores, two for my state public health sites), not one of them is really designed for the person in the family who does the scheduling for most of the family members.  These same changes could readily enable others who volunteer to assist others in scheduling work more efficiently.

    There are balancing factors. Making it easy for one person to schedule multiple appointments at the same time and location would benefit families, but single individuals living alone would be disadvantaged by such a system.  But if there are enough vaccines (and appointments) to go around, this would be less of a problem.

    We're likely going to be scheduling shots for some time yet.  We've only gotten shots into the arms of about half of the US population, and these aren't likely to be the last COVID-19 shots that have to be given.  Booster shots are expected by some vaccine manufacturers.



    Monday, April 12, 2021

    Recovering Deleted Data in an Excel Tab (When you have a PivotTable)


     Life happens sometimes. Is this you?

    1. You leave multiple programs running because you have a few meetings to get through for the day.
    2. You hope to get back to that spreadsheet you started working on in the early morning.  
    3. You operate Excel pretty well using the keyboard, and touch type in it and other programs.
    4. Eventually, you get to the next day, discover you never got back to the spreadsheet, and so close and save it promising you will finish it up later that day.
    5. You reopen it later and find out the tab you were working on was missing.
    6. Because you downloaded it and never turned on "version control", you don't have the old version.
    7. Or a copy of what you worked on (and the changes are to recently made to have a backup).
    8. But it DOES have a pivot table summarizing the data in your missing tab.
    9. Somewhere during your calls you hit both Delete and Enter at the same time and managed to delete the Excel sheet b/c you were typing and talking at the same time, and paying attention to the Zoom call, and didn't see that you'd deleted the tab you just spend hours working through.
    This post may save your data, but only if you are a bit of a computer geek and know a little something about XML. Using the techniques below managed to save mine.

    What you may not know is that:
    • Later version of Microsoft Office tools, including Excel use the ZIP format to store the collection of XML files they use to store data in the new formats.  
    • Whenever you create a Pivot Table in Excel from data in your sheet (or from other data sources), Excel caches a snapshot of the data for local processing, and only refreshes from the original source when you Refresh the Pivot Table.  If you DON'T Refresh the Data in the Pivot Table, it's still in your spreadsheet.
    Here is what you need to do:

    1. Make a copy of the affected file in a new work folder.
    2. If the copied file is in .XLS format, open it in Excel, and Save as a .XLSX.  This will simply change the file format, the data you need will still be in it, but now in a format that can be more readily accessed.
    3. Rename the file from *.XLSX to *.ZIP.
    Next, look for xl/pivotCache/pivotCacheDefinition1.xml and xl/pivotCache/pivotCacheRecords1.xml in the ZIP file.  If you have more than one Pivot Table, you might need to look at the files ending in a different number.

    The pivotCacheDefinition file contains the column heading names in the cacheField element.
    You can verify the data source in the worksheetSource element.
    The pivotCacheRecords file contains the rows of the sheet in the <r> elements.
    Empty cells are reported in <m/> elements.
    The values are found in the v attribute of elements named <b> (boolean), <d> (date), <e> (error), <n> (numeric) and <s> (string).
    Some elements where the values are repeated a lot use <x>, in which case the v attribute indicates the index of the sharedItems found in the cacheField element in the pivotCacheDefinition file.  This gets a little bit complicated.

    Assuming you've extracted those two files, the following XSLT will regenerate the sheet in CSV format when run over the extracted pivotCacheRecords.xml file.


    <?xml version="1.0" encoding="UTF-8"?>
    <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
        xmlns:xs="http://www.w3.org/2001/XMLSchema"
        exclude-result-prefixes="xs"
        xmlns:m="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
        version="2.0">
        <xsl:output  method="text"/>
        <xsl:variable name="doc" select="doc('pivotCacheDefinition1.xml')"/>
        <xsl:template match='/'>
            <xsl:for-each select='$doc//*:cacheField/@name'>
                <xsl:if test='position()!=1'>,</xsl:if>
                <xsl:value-of select='.'/>
            </xsl:for-each>
            <xsl:text>&#xA;</xsl:text>
            <xsl:for-each select='.//m:r'>
                <xsl:for-each select='*'>
                    <xsl:if test='position()!=1'>,</xsl:if>
                    <xsl:variable name="pos" select="position()"/>
                    <xsl:choose>
                        <xsl:when test="self::m:m"></xsl:when>
                        <xsl:when test="self::m:x">
                            <xsl:variable name="v" select="@v + 1"/>
                            <xsl:variable name="x" select="$doc//m:cacheField[$pos]/m:sharedItems/*[$v]/@v"/>
                            <xsl:if test='$x'>"</xsl:if>
                            <xsl:value-of select="replace($x,'&quot;','&quot;&quot;')"/>
                            <xsl:if test='$x'>"</xsl:if>
                        </xsl:when>
                        <xsl:otherwise>
                            <xsl:text>"</xsl:text>
                            <xsl:value-of select="replace(@v,'&quot;','&quot;&quot;')"/>
                            <xsl:text>"</xsl:text>
                        </xsl:otherwise>
                    </xsl:choose>
                </xsl:for-each>
                <xsl:text>&#xA;</xsl:text>
            </xsl:for-each>
        </xsl:template>
    </xsl:stylesheet>



    Friday, April 2, 2021

    From Risk to Opportunity

    If you've been in healthcare or IT for a while, you've probably heard about Risk Assessments.  And if you have been through a few of these, you might recall the process of:

    1. Identifying assets to protect
    2. Enumerating threats to those assets in a list of risks
    3. Assessing the likelihood of the risk happening
    4. Assessing the level of impact of the risk (i.e., how bad it would be if the threat occurred)
    5. Using the categories values from #3 and #4 to assess the risk level (to understand what needs to be mitigated) using something like the matrices below:


    Several resources are available from HL7 and IHE to do these tasks, including the HL7 Security Cookbook and the the IHE Risk Management in Healthcare IT Whitepaper (from which the two images above were drawn).

    But I'm not really here to talk about Risk Assessment.  I'm going to the other end of the spectrum to talk about how you can use this same framework to prioritize efforts for opportunities, and it works pretty much the same way.
    1. Identify the assets to capitalize on (assets being used very loosely here, it could include processes and skills that you are good at, as well as the usual notion of assets).
    2. Identify the value of those assets in a list of opportunities.
    3. Assessing the likelihood of the opportunity succeeding.
    4. Assessing the level of impact of the opportunity (e.g., ROI).
    5. Assessing the importance of the opportunity.
    The same way a risk assessment helps to identify the risks to mitigate, an opportunity assessment can help you identify opportunities to explore further, and skip the ones which are of lower importance.  You could also replace "likelihood" with "cost", where cost is essentially a proxy for likelihood, but high cost is equivalent to low likelihood, and so you'd have to flip one axis of the grid.

    The number of levels of opportunity (or risk for that matter) that one puts in the grid is really up to the organization performing the assessment, I'd recommend using at least 3 and no more than 5.

    The benefit of using this framework for assessing opportunities is very similar to the befit for following it for risk assessments.  It puts a structure around the work that you are doing, and adds some degree of objectivity to the assessment process.  It still requires judgement (and that may be subjective), but the results will give you more insight and confidence in the outcomes.

    For what it's worth, this isn't my idea.  I THINK I first heard about this from Gila Pyke about seven or eight years ago, probably over Sushi somewhere at an HL7 or IHE meeting.