Oil & Gas Coding in Python (Part 2)

Continuing to follow in @fauxright’s footsteps, this blog post will show you how to generate a decline curve forecast using python. The equivalent R based post can be found here.


To Hell With Libraries!

At this point, I will assume you have python installed and functioning on your machine.

I will not be adding a decline curve package/library, though a few already exist for python.


Dash

I will also need to add Dash from plotly as a package to help us render an interactive visualization.

In your python command prompt (this step may vary by your installation), use the following code to install the necessary libraries.

pip install dash

APPLICATION INITIALIZATION

Once that is finished, open your python IDE (text editor, Spyder, pycharm, etc…) and create a file named app.py. The code below will be all of the libraries we will import for the application.

#Plotly Dash Ecosystem
import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output

#Go-To Data Libraries
import pandas as pd
import numpy as np
import plotly.express as px

The top 4 imports are specific to the plotly Dash ecosystem and will provide me with visual elements similar to a web page, but without coding HTML and JavaScript.

Pandas and Numpy are go-to python libraries for many functions and should definitely be in your tool kit. I won’t go into them since they are vast, but I’ll try to cover the functions I use below.

I’ll skip over some parts of the total project at times to try and focus on things in a logical order. If you want to just copy and paste the work, wait until the code is shown in full at the end.

# set up the initial decline curve parameters to be used
initialParams = [200000, 0.75, 0.7, 0.1, 4, 10]

...

# Initializing our Dash application
app = dash.Dash(__name__)

...

app.layout = html.Div([
    html.H1('Type Curve Example', className='twelve columns'),
    html.Div([
        html.Label('Gas IP, Mcf'),
        dcc.Input(id='IP',type='number', value=initialParams[0]),
        html.Label('B-Factor'),
        dcc.Input(id='Bfactor',type='number', value=initialParams[1]),
        html.Label('Initial Decline'),
        dcc.Input(id='EADi',type='number', value=initialParams[2]),
        html.Label('Terminal Decline'),
        dcc.Input(id='EADt',type='number', value=initialParams[3]),
        html.Label('Flat Months'),
        dcc.Input(id='FlatMonths',type='number', value=initialParams[4]),
        html.Label('Well life, Yrs'),
        dcc.Input(id='WellLife',type='number', value=initialParams[5]),
    ], className='two columns'),
    html.Div([
        dcc.Graph(id='DCA-graphic', figure=fig)
    ], className='eight columns')
],)

What the Hell did I just do?

Initial Decline Curve Parameters

After importing the necessary libraries, I create a variable (initialParams) to hold my initial decline curve parameters. This will be used throughout the application.


Initialize Application

Following that, I initialize the application, which will later be run on a local webserver. Dash is run on top of Flask, in case you have some familiarity with that, though not necessary at the moment.


App Layout

Next, I set up one of the core parts of a Dash application, app.layout.

The layout section is where I set up the user interface I want to generate in the application.

The naming conventions may seem familiar if you have any HTML experience (div, label, H1).

In general, I have set up two divs (frames in the web browser) (see Figure 1 below).

A div that will be shown on the left contains labels and inputs for the user to modify decline curve parameters.

The div that will be shown on the right side of the browser will show a line chart of my decline curve projection. Again,

Review the image below and the code above. If it doesn’t click, don’t be afraid to get in touch with questions.

Figure 1: Decline Curve Interface Example

App Reactivity

Now that we have set up our layout, I need to:

  1. Generate some functions to handle the data
  2. Generate the graph
  3. Perform any necessary actions as a user makes changes to the inputs.

I want this to be a living and breathing web application in case the banks call and I need to pump up those B factors!

...

# Method to generate the decline curve and graph from the initial parameters
def initiate_graph(IP, BFactor, EADi, EADt, FlatMonths, WellLife):
    global initialParams 
    initialParams = [IP, BFactor, EADi, EADt, FlatMonths, WellLife]
    
    # generate a pandas dataframe to hold our time column
    modelTime = pd.DataFrame({'Month': np.arange(0, 12*initialParams[5],1)})
    ratePrediction = modified_hyperbolic_decline(modelTime,
                                                 initialParams[0],initialParams[1],
                                                 initialParams[2], initialParams[3])
    
    # inserting our predicted production rates into our modelTime DataFrame
    # Note: we are shifting the production rate data by the number of flat months (initialParams[4])
    # and then filling those spaces with our initial rate (Qi, initialParams[0])
    modelTime['Production Rate'] = ratePrediction.shift(initialParams[4],fill_value=initialParams[0])
    fig = px.line(modelTime, x="Month", y="Production Rate")
    fig.update_yaxes(range=[0, initialParams[0]*1.1])
    fig.update_traces(marker_size=10)
    return fig

...

@app.callback(
    Output('DCA-graphic','figure'),
    [Input('IP', 'value'),
      Input('Bfactor', 'value'),
      Input('EADi', 'value'),
      Input('EADt', 'value'),
      Input('FlatMonths', 'value'),
      Input('WellLife', 'value')]
)
def update_graph(IP, BFactor, EADi, EADt, FlatMonths, WellLife):
    return initiate_graph(IP, BFactor, EADi, EADt, FlatMonths, WellLife)

The above code may seem like a lot, but I wanted to show it together so that it made more sense.


Initiate Graph

The initiate_graph method is key to keeping everything updated on our user interface. It takes 5 inputs (listed out beyond the function name). These also happen to be the items I put in my initialParams variable (see the code snippet after all of the imports).

In python, to reference a global variable, I have to call it inside the function (global initialParams). Otherwise python thinks I am making a new local variable with the same name (but not holding the values I am looking for/hoping to update).


Time Function

The variable modelTime is a generated timeline that I want to have the decline curve data over. Here I utilize both Pandas and Numpy to generate enough rows for my data. You’ll notice I reference initialParams[5], which is the “Well Life” parameter in years. The modelTime DataFrame is immediately used as the time function being passed to the modified_hyperbolic_decline function. I review it in detail shortly, so bear with me.

For now, let’s consider the modified_hyperbolic_decline function a black box that returns our decline curve projected production rates to the variable “ratePrediction“.


Production Rate

One somewhat complex piece of code in this section is the line where I am adding a column (“Production Rate“) to the modelTime DataFrame and inserting the returned curve projections. In this specific line, I call .shift, which is equivalent to inserting rows above (think excel). I am then shifting my production rates by the number of “Flat Months” in my inputs.

The “fill_value” option allows me to input my initial production rate in what would have been null values for those initial slots.

I’ve added a few comment lines in that code in hopes that it makes sense when this wall of text isn’t available to copilot.


Event Triggering

Skipping down to the next section of code, application callbacks are an important part of the Dash library. They allow me to trigger events and update data. This code tells the web interface that on any modification to the inputs listed (basically the entire left column), I need to update the graph on the right.

The plotly/Dash site is a great place to get the details on how this works if you have a deeper interest.

The key takeaway here is that whenever a user modifies any of the decline curve input variables, the function “update_graph” is called with all of the updated data. This calls the function initiate_graph that I previously stepped through in the paragraph above. Simple, yet slick.

# unused - ARPs hyperbolic equation
def arps_decline(T,Qi,B,Di):
    Di = -np.log(1-Di/12)
    return Qi/np.power((1+B*Di*T),1./B)

# Modified Hyperbolic Decline function utilized to generate the decline forecast
def modified_hyperbolic_decline(T,Qi,B,Di,Dlim):
    # Some minor logic here to determine which D factor to use
    
    #convert D from annual effective to nominal monthly
    Di = -np.log(1-Di/12)
    Dlim = -np.log(1-Dlim/12)
    
    usedD = 1/((1/Di)+(B*T))
    usedD = usedD.where(usedD > Dlim,Dlim)
    return Qi/np.power((1+B*usedD*T),1./B)

Now to the meat of the whole thing

Above, I am showing two separate decline curve functions (the ARPs hyperbolic method is not utilized in this example application). I can change decline methods by just modifying a few lines of code (swap out modified_hyperbolic_decline in the initiate_graph function).

I’ll spend this space talking about the modified hyperbolic decline function, since that one is live in the example. The inputs here are unique in that we are mixing vectors and scalars.

  1. The variable ‘T‘ is our timeline generated previously (variable modelTime), which is an array of months from 0 to x.
  2. The remaining inputs are scalars that will be used to generate my production rate for each time period within variable ‘T‘.
  3. Specific to the modified hyperbolic decline, I need to calculate D and check if I have hit the terminal decline point in the projection (usedD > Dlim logic seen above).
  4. Finally, I return an array of production rates that will be plotted on my graph.

I briefly mentioned at the start that we would not be importing any python libraries with decline curve functions built in. One of the reasons why is because it is important to see just how simple it is to create such a function. If you would prefer to run another style of decline curve, simply add that function with a similar input/output style and you are off to the races.

Frac lost

Testing

if __name__ == '__main__':
    app.run_server(debug=False, port=1234)

The final code snippet launches a web server with our application on my local machine so that I can test.

I can run my application from the python command prompt (python myapp.py) or (at least using Spyder, the IDE I am familiar with) hitting F5. The console in Spyder will show something like “Dash is running on http://127.0.0.1:1234“, which is the web address you should put in your browser to see your application come to life.

Of course, this is a bare bones application, so feel free to add on once you feel comfortable with the framework I have set out. Calculate the EUR, import and plot actual well data to compare to the decline generated, prevent a user from using negative values, implement a different decline curve function (you know, exponentially stretch your duong all you want). The full code is below for the copy/pasta bandits!

@fraclost


Full Code Below

# -*- coding: utf-8 -*-
"""
Created on Sat Sep  5 22:18:36 2020

@author: @fraclost
"""

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output
import pandas as pd
import numpy as np
import plotly.express as px

# set up the initial decline curve parameters to be used
initialParams = [200000, 0.75, 0.7, 0.1, 4, 10]

# unused - ARPs hyperbolic equation
def arps_decline(T,Qi,B,Di):
    return Qi/np.power((1+B*Di*T),1./B)

# Modified Hyperbolic Decline function utilized to generate the decline forecast
def modified_hyperbolic_decline(T,Qi,B,Di,Dlim):
    # Some minor logic here to determine which D factor to use
    usedD = 1/((1/Di)+(B*T))
    usedD = usedD.where(usedD > Dlim,Dlim) 
    return Qi/np.power((1+B*usedD*T),1./B)

# Initializing our Dash application
app = dash.Dash(__name__)

# Method to generate the decline curve and graph from the initial parameters
def initiate_graph(IP, BFactor, EADi, EADt, FlatMonths, WellLife):
    global initialParams 
    initialParams = [IP, BFactor, EADi, EADt, FlatMonths, WellLife]
    
    # generate a pandas dataframe to hold our time column
    modelTime = pd.DataFrame({'Month': np.arange(0, 12*initialParams[5],1)})
    ratePrediction = modified_hyperbolic_decline(modelTime,
                                                 initialParams[0],initialParams[1],
                                                 initialParams[2], initialParams[3])
    
    # inserting our predicted production rates into our modelTime DataFrame
    # Note: we are shifting the production rate data by the number of flat months (initialParams[4])
    # and then filling those spaces with our initial rate (Qi, initialParams[0])
    modelTime['Production Rate'] = ratePrediction.shift(initialParams[4],fill_value=initialParams[0])
    fig = px.line(modelTime, x="Month", y="Production Rate")
    fig.update_yaxes(range=[0, initialParams[0]*1.1])
    fig.update_traces(marker_size=10)
    return fig

# generate initial decline curve
fig = initiate_graph(initialParams[0],initialParams[1],initialParams[2], 
                     initialParams[3],initialParams[4],initialParams[5])

app.layout = html.Div([
    html.H1('Type Curve Example', className='twelve columns'),
    html.Div([
        html.Label('Gas IP, Mcf'),
        dcc.Input(id='IP',type='number', value=initialParams[0]),
        html.Label('B-Factor'),
        dcc.Input(id='Bfactor',type='number', value=initialParams[1]),
        html.Label('Initial Decline'),
        dcc.Input(id='EADi',type='number', value=initialParams[2]),
        html.Label('Terminal Decline'),
        dcc.Input(id='EADt',type='number', value=initialParams[3]),
        html.Label('Flat Months'),
        dcc.Input(id='FlatMonths',type='number', value=initialParams[4]),
        html.Label('Well life, Yrs'),
        dcc.Input(id='WellLife',type='number', value=initialParams[5]),
    ], className='two columns'),
    html.Div([
        dcc.Graph(id='DCA-graphic', figure=fig)
    ], className='eight columns')
],)


@app.callback(
    Output('DCA-graphic','figure'),
    [Input('IP', 'value'),
      Input('Bfactor', 'value'),
      Input('EADi', 'value'),
      Input('EADt', 'value'),
      Input('FlatMonths', 'value'),
      Input('WellLife', 'value')]
)
def update_graph(IP, BFactor, EADi, EADt, FlatMonths, WellLife):
    return initiate_graph(IP, BFactor, EADi, EADt, FlatMonths, WellLife)


if __name__ == '__main__':
    app.run_server(debug=False, port=1234)

Update

After posting the initial draft, I noticed that I did not clarify some of the styling that is being used to make things look smooth on the web page. Dash uses cascading style sheets (CSS) to instruct the web browser on how to layout the different items on a page. You may have noticed that in the app.layout section of the code, I call something like below.

...
    html.Div([
        dcc.Graph(id='DCA-graphic', figure=fig)
    ], className='eight columns')
...

The CSS is referenced here by the use of className. Specific to this example, I am telling the web browser to display this particular div element in the styling of whatever is defined under the name “eight columns” in my CSS code. To have the same CSS as the visual above, create a folder called “assets” in the same location as the app.py file. Inside of the assets folder, create a CSS file and copy the following code.

/* Table of contents
––––––––––––––––––––––––––––––––––––––––––––––––––
- Plotly.js
- Grid
- Base Styles
- Typography
- Links
- Buttons
- Forms
- Lists
- Code
- Tables
- Spacing
- Utilities
- Clearing
- Media Queries
*/

/* PLotly.js 
–––––––––––––––––––––––––––––––––––––––––––––––––– */
/* plotly.js's modebar's z-index is 1001 by default
 * https://github.com/plotly/plotly.js/blob/7e4d8ab164258f6bd48be56589dacd9bdd7fded2/src/css/_modebar.scss#L5
 * In case a dropdown is above the graph, the dropdown's options
 * will be rendered below the modebar
 * Increase the select option's z-index
 */

/* This was actually not quite right -
   dropdowns were overlapping each other (edited October 26)

.Select {
    z-index: 1002;
}*/


/* Grid
–––––––––––––––––––––––––––––––––––––––––––––––––– */
.container {
  position: relative;
  width: 100%;
  max-width: 960px;
  margin: 0 auto;
  padding: 0 20px;
  box-sizing: border-box; }
.column,
.columns {
  width: 100%;
  float: left;
  box-sizing: border-box; }

/* For devices larger than 400px */
@media (min-width: 400px) {
  .container {
    width: 85%;
    padding: 0; }
}

/* For devices larger than 550px */
@media (min-width: 550px) {
  .container {
    width: 80%; }
  .column,
  .columns {
    margin-left: 4%; }
  .column:first-child,
  .columns:first-child {
    margin-left: 0; }

  .one.column,
  .one.columns                    { width: 4.66666666667%; }
  .two.columns                    { width: 13.3333333333%; }
  .three.columns                  { width: 22%;            }
  .four.columns                   { width: 30.6666666667%; }
  .five.columns                   { width: 39.3333333333%; }
  .six.columns                    { width: 48%;            }
  .seven.columns                  { width: 56.6666666667%; }
  .eight.columns                  { width: 65.3333333333%; }
  .nine.columns                   { width: 74.0%;          }
  .ten.columns                    { width: 82.6666666667%; }
  .eleven.columns                 { width: 91.3333333333%; }
  .twelve.columns                 { width: 100%; margin-left: 0; }

  .one-third.column               { width: 30.6666666667%; }
  .two-thirds.column              { width: 65.3333333333%; }

  .one-half.column                { width: 48%; }

  /* Offsets */
  .offset-by-one.column,
  .offset-by-one.columns          { margin-left: 8.66666666667%; }
  .offset-by-two.column,
  .offset-by-two.columns          { margin-left: 17.3333333333%; }
  .offset-by-three.column,
  .offset-by-three.columns        { margin-left: 26%;            }
  .offset-by-four.column,
  .offset-by-four.columns         { margin-left: 34.6666666667%; }
  .offset-by-five.column,
  .offset-by-five.columns         { margin-left: 43.3333333333%; }
  .offset-by-six.column,
  .offset-by-six.columns          { margin-left: 52%;            }
  .offset-by-seven.column,
  .offset-by-seven.columns        { margin-left: 60.6666666667%; }
  .offset-by-eight.column,
  .offset-by-eight.columns        { margin-left: 69.3333333333%; }
  .offset-by-nine.column,
  .offset-by-nine.columns         { margin-left: 78.0%;          }
  .offset-by-ten.column,
  .offset-by-ten.columns          { margin-left: 86.6666666667%; }
  .offset-by-eleven.column,
  .offset-by-eleven.columns       { margin-left: 95.3333333333%; }

  .offset-by-one-third.column,
  .offset-by-one-third.columns    { margin-left: 34.6666666667%; }
  .offset-by-two-thirds.column,
  .offset-by-two-thirds.columns   { margin-left: 69.3333333333%; }

  .offset-by-one-half.column,
  .offset-by-one-half.columns     { margin-left: 52%; }

}


/* Base Styles
–––––––––––––––––––––––––––––––––––––––––––––––––– */
/* NOTE
html is set to 62.5% so that all the REM measurements throughout Skeleton
are based on 10px sizing. So basically 1.5rem = 15px :) */
html {
  font-size: 62.5%; }
body {
  font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */
  line-height: 1.6;
  font-weight: 400;
  font-family: "Open Sans", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
  color: rgb(50, 50, 50); }


/* Typography
–––––––––––––––––––––––––––––––––––––––––––––––––– */
h1, h2, h3, h4, h5, h6 {
  margin-top: 0;
  margin-bottom: 0;
  font-weight: 300; }
h1 { font-size: 4.5rem; line-height: 1.2;  letter-spacing: -.1rem; margin-bottom: 2rem; }
h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; margin-bottom: 1.8rem; margin-top: 1.8rem;}
h3 { font-size: 3.0rem; line-height: 1.3;  letter-spacing: -.1rem; margin-bottom: 1.5rem; margin-top: 1.5rem;}
h4 { font-size: 2.6rem; line-height: 1.35; letter-spacing: -.08rem; margin-bottom: 1.2rem; margin-top: 1.2rem;}
h5 { font-size: 2.2rem; line-height: 1.5;  letter-spacing: -.05rem; margin-bottom: 0.6rem; margin-top: 0.6rem;}
h6 { font-size: 2.0rem; line-height: 1.6;  letter-spacing: 0; margin-bottom: 0.75rem; margin-top: 0.75rem;}

p {
  margin-top: 0; }


/* Blockquotes
–––––––––––––––––––––––––––––––––––––––––––––––––– */
blockquote {
  border-left: 4px lightgrey solid;
  padding-left: 1rem;
  margin-top: 2rem;
  margin-bottom: 2rem;
  margin-left: 0rem;
}


/* Links
–––––––––––––––––––––––––––––––––––––––––––––––––– */
a {
  color: #1EAEDB; 
  text-decoration: underline;
  cursor: pointer;}
a:hover {
  color: #0FA0CE; }


/* Buttons
–––––––––––––––––––––––––––––––––––––––––––––––––– */
.button,
button,
input[type="submit"],
input[type="reset"],
input[type="button"] {
  display: inline-block;
  height: 38px;
  padding: 0 30px;
  color: #555;
  text-align: center;
  font-size: 11px;
  font-weight: 600;
  line-height: 38px;
  letter-spacing: .1rem;
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
  background-color: transparent;
  border-radius: 4px;
  border: 1px solid #bbb;
  cursor: pointer;
  box-sizing: border-box; }
.button:hover,
button:hover,
input[type="submit"]:hover,
input[type="reset"]:hover,
input[type="button"]:hover,
.button:focus,
button:focus,
input[type="submit"]:focus,
input[type="reset"]:focus,
input[type="button"]:focus {
  color: #333;
  border-color: #888;
  outline: 0; }
.button.button-primary,
button.button-primary,
input[type="submit"].button-primary,
input[type="reset"].button-primary,
input[type="button"].button-primary {
  color: #FFF;
  background-color: #33C3F0;
  border-color: #33C3F0; }
.button.button-primary:hover,
button.button-primary:hover,
input[type="submit"].button-primary:hover,
input[type="reset"].button-primary:hover,
input[type="button"].button-primary:hover,
.button.button-primary:focus,
button.button-primary:focus,
input[type="submit"].button-primary:focus,
input[type="reset"].button-primary:focus,
input[type="button"].button-primary:focus {
  color: #FFF;
  background-color: #1EAEDB;
  border-color: #1EAEDB; }


/* Forms
–––––––––––––––––––––––––––––––––––––––––––––––––– */
input[type="email"],
input[type="number"],
input[type="search"],
input[type="text"],
input[type="tel"],
input[type="url"],
input[type="password"],
textarea,
select {
  height: 38px;
  padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
  background-color: #fff;
  border: 1px solid #D1D1D1;
  border-radius: 4px;
  box-shadow: none;
  box-sizing: border-box; 
  font-family: inherit;
  font-size: inherit; /*https://stackoverflow.com/questions/6080413/why-doesnt-input-inherit-the-font-from-body*/}
/* Removes awkward default styles on some inputs for iOS */
input[type="email"],
input[type="number"],
input[type="search"],
input[type="text"],
input[type="tel"],
input[type="url"],
input[type="password"],
textarea {
  -webkit-appearance: none;
     -moz-appearance: none;
          appearance: none; }
textarea {
  min-height: 65px;
  padding-top: 6px;
  padding-bottom: 6px; }
input[type="email"]:focus,
input[type="number"]:focus,
input[type="search"]:focus,
input[type="text"]:focus,
input[type="tel"]:focus,
input[type="url"]:focus,
input[type="password"]:focus,
textarea:focus,
select:focus {
  border: 1px solid #33C3F0;
  outline: 0; }
label,
legend {
  display: block;
  margin-bottom: 0px; }
fieldset {
  padding: 0;
  border-width: 0; }
input[type="checkbox"],
input[type="radio"] {
  display: inline; }
label > .label-body {
  display: inline-block;
  margin-left: .5rem;
  font-weight: normal; }


/* Lists
–––––––––––––––––––––––––––––––––––––––––––––––––– */
ul {
  list-style: circle inside; }
ol {
  list-style: decimal inside; }
ol, ul {
  padding-left: 0;
  margin-top: 0; }
ul ul,
ul ol,
ol ol,
ol ul {
  margin: 1.5rem 0 1.5rem 3rem;
  font-size: 90%; }
li {
  margin-bottom: 1rem; }


/* Tables
–––––––––––––––––––––––––––––––––––––––––––––––––– */
table {
  border-collapse: collapse;
}
th:not(.CalendarDay),
td:not(.CalendarDay) {
  padding: 12px 15px;
  text-align: left;
  border-bottom: 1px solid #E1E1E1; }
th:first-child:not(.CalendarDay),
td:first-child:not(.CalendarDay) {
  padding-left: 0; }
th:last-child:not(.CalendarDay),
td:last-child:not(.CalendarDay) {
  padding-right: 0; }


/* Spacing
–––––––––––––––––––––––––––––––––––––––––––––––––– */
button,
.button {
  margin-bottom: 0rem; }
input,
textarea,
select,
fieldset {
  margin-bottom: 0rem; }
pre,
dl,
figure,
table,
form {
  margin-bottom: 0rem; }
p,
ul,
ol {
  margin-bottom: 0.75rem; }

/* Utilities
–––––––––––––––––––––––––––––––––––––––––––––––––– */
.u-full-width {
  width: 100%;
  box-sizing: border-box; }
.u-max-full-width {
  max-width: 100%;
  box-sizing: border-box; }
.u-pull-right {
  float: right; }
.u-pull-left {
  float: left; }


/* Misc
–––––––––––––––––––––––––––––––––––––––––––––––––– */
hr {
  margin-top: 3rem;
  margin-bottom: 3.5rem;
  border-width: 0;
  border-top: 1px solid #E1E1E1; }


/* Clearing
–––––––––––––––––––––––––––––––––––––––––––––––––– */

/* Self Clearing Goodness */
.container:after,
.row:after,
.u-cf {
  content: "";
  display: table;
  clear: both; }


/* Media Queries
–––––––––––––––––––––––––––––––––––––––––––––––––– */
/*
Note: The best way to structure the use of media queries is to create the queries
near the relevant code. For example, if you wanted to change the styles for buttons
on small devices, paste the mobile query code up in the buttons section and style it
there.
*/


/* Larger than mobile */
@media (min-width: 400px) {}

/* Larger than phablet (also point when grid becomes active) */
@media (min-width: 550px) {}

/* Larger than tablet */
@media (min-width: 750px) {}

/* Larger than desktop */
@media (min-width: 1000px) {}

/* Larger than Desktop HD */
@media (min-width: 1200px) {}

I am not going to cover the CSS code in any detail. There are plenty of references on the internet that can help you dive much deeper than I can. Once you have the CSS file created in the assets folder, your application should adhere to the new CSS rules and render cleanly as a two column interface.

1 thought on “Oil & Gas Coding in Python (Part 2)”

  1. Pingback: Explaining the Shiny App (Oil & Gas Coding Series) - Shale Insights

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: