diff --git a/README.md b/README.md
index d806ba9..5f5d961 100644
--- a/README.md
+++ b/README.md
@@ -45,26 +45,6 @@ Please make sure to update tests as appropriate.
[RUNBOOK](runbook.md)
-## Interpolating new fraction data
-
-Bridge data from products on a different scale than the one defined at `api/calculators/bridge.py:45` can be added to
-the LCM optimizer as long as the data gets interpolated into the same scale.
-
-That can be done like this;
-
-1. Add a file at `./api/test_data/interpolate_input.csv`
-2. Have the first column be called "Size" and have 101 measuring points of the products
-3. Add one column for each product, where the header is the name of the product.
- ```csv
- Size,Prod1,Prod2
- 0.01,0,0
- 0.011482,0,0
- ...
- 10000,100,100
- ```
-4. Run `docker-compose build api && docker-compose run api python calculators/fraction_interpolator.py`
-5. One result file for each product will be created in `./api/test_data/`
-
## Radix
Two different environments in Radix are used: one for test (deploys from branch "test") and one for production (deploy from branch "master")
diff --git a/api/src/app.py b/api/src/app.py
index 6f79114..a3b251e 100644
--- a/api/src/app.py
+++ b/api/src/app.py
@@ -7,7 +7,7 @@
from controllers.combination import bridge_from_combination
from controllers.optimal_bridge import bridgeRequestHandler
from controllers.optimizer import optimizer_request_handler
-from controllers.products import products_get
+from controllers.products import products_get, products_post
from controllers.report import create_report
from util.authentication import authorize
from util.sync_share_point_az import sync_all
@@ -22,10 +22,17 @@ def init_api():
app = init_api()
-@app.route("/api/products", methods=["GET"])
+@app.route("/api/products", methods=["GET", "POST"])
@authorize
-def products():
- return products_get()
+def products() -> Response:
+ if request.method == "GET":
+ return products_get()
+ if request.method == "POST":
+ name: str = request.json.get("productName")
+ supplier: str = request.json.get("productSupplier")
+ product_data: [[float, float]] = request.json.get("productData")
+ return products_post(name, supplier, product_data)
+ raise ValueError("Invalid method for endpoint")
@app.route("/api/report", methods=["POST"])
diff --git a/api/src/calculators/fraction_interpolator.py b/api/src/calculators/fraction_interpolator.py
index 7642fff..7456b48 100644
--- a/api/src/calculators/fraction_interpolator.py
+++ b/api/src/calculators/fraction_interpolator.py
@@ -1,63 +1,44 @@
-import csv
-from copy import copy
+from bisect import bisect_left
from numpy import log
from calculators.bridge import SIZE_STEPS
-def lookup_smaller(table: dict, value: float):
- n = [i for i in table.keys() if i <= value]
- return max(n)
+def find_closest_bigger_index(array: list[float], target: float) -> int:
+ index = bisect_left(array, target)
+ return index + 1
-def lookup_bigger(table: dict, value: float):
- n = [i for i in table.keys() if i >= value]
- return min(n)
+def log_interpolate_or_extrapolate(xMin: float, yMin: float, xMax: float, yMax: float, z: float) -> float:
+ increase = (log(z) - log(xMin)) / (log(xMax) - log(xMin))
+ return increase * (yMax - yMin) + yMin
-def fraction_interpolator(x: list[float], y: list[float], z: list[float]) -> list[float]:
- table_dict = dict(zip(x, y, strict=False))
- max_x = max(x)
+def fraction_interpolator_and_extrapolator(
+ xArray: list[float], yArray: list[float], zArray: list[float] = SIZE_STEPS
+) -> list[float]:
+ sizes_dict = {size: 0 for size in zArray} # Populate size_dict with 0 values
+ starting_index = find_closest_bigger_index(zArray, min(xArray)) - 1
- z_table = {}
- for _z in z:
- if _z > max_x:
- break
- smaller_x = lookup_smaller(table_dict, _z)
- bigger_x = lookup_bigger(table_dict, _z)
- z_table[_z] = {"x1": smaller_x, "x2": bigger_x, "y1": table_dict[smaller_x], "y2": table_dict[bigger_x]}
+ for zIndex, z in enumerate(zArray[starting_index:]):
+ if z < xArray[0]: # Don't extrapolate down from first measuring point
+ continue
+ # If z is above the range of xArray, use the last two points for extrapolation
+ elif z > xArray[-1]:
+ yz = log_interpolate_or_extrapolate(xArray[-2], yArray[-2], xArray[-1], yArray[-1], z)
+ else:
+ # Find the interval that z falls into for interpolation
+ for i in range(1, len(xArray)):
+ if xArray[i - 1] <= z <= xArray[i]:
+ yz = log_interpolate_or_extrapolate(xArray[i - 1], yArray[i - 1], xArray[i], yArray[i], z)
+ break
- for zz, values in z_table.items():
- x1 = values["x1"]
- x2 = values["x2"]
- y1 = values["y1"]
- y2 = values["y2"]
+ if yz > 100: # 100% volume has been reached. Stop extrapolation. Set all remaining to 100%
+ for key in zArray[zIndex + starting_index :]:
+ sizes_dict[key] = 100
+ return list(sizes_dict.values())
- values["j"] = (y2 - y1) / (log(x2) - log(x1)) * (log(zz) - log(x1)) + y1
- return [round(v["j"], 3) for v in z_table.values()]
+ sizes_dict[z] = round(yz, ndigits=3)
-
-def from_csv_to_csv():
- with open("test_data/interpolate_input.csv") as csvfile:
- reader = csv.DictReader(csvfile)
- fields_copy = copy(reader.fieldnames)
- fields_copy.pop(0)
- products = {name: [] for name in fields_copy}
- a_x = []
- for line in reader:
- a_x.append(float(line["Size"]))
- for n in products:
- products[n].append(float(line[n]))
-
- for name in products:
- b_y = fraction_interpolator(x=a_x, y=products[name], z=SIZE_STEPS)
- with open(f"test_data/{name}.csv", "w+") as newcsvfile:
- writer = csv.DictWriter(newcsvfile, fieldnames=["Size", "Cumulative"])
- writer.writeheader()
- for step, interpol_value in zip(SIZE_STEPS, b_y, strict=False):
- writer.writerow({"Size": step, "Cumulative": interpol_value})
-
-
-if __name__ == "__main__":
- from_csv_to_csv()
+ return list(sizes_dict.values())
diff --git a/api/src/config.py b/api/src/config.py
index bc73125..6b7f605 100644
--- a/api/src/config.py
+++ b/api/src/config.py
@@ -4,6 +4,7 @@
class Config:
+ AUTH_DISABLED = os.getenv("AUTH_DISABLED", "false")
TABLE_ACCOUNT_NAME = os.getenv("TABLE_ACCOUNT_NAME", "lcmdevstorage")
TABLE_KEY = os.getenv("TABLE_KEY")
BLOB_CONTAINER_NAME = "lcm-file-blobs"
@@ -19,4 +20,5 @@ class Config:
DEFAULT_MAX_ITERATIONS = 100
HOME_DIR = str(Path(__file__).parent.absolute())
PRODUCT_TABLE_NAME = "products"
+ CUSTOM_PRODUCT_TABLE = "interpolatedproducts"
named_supplier = ("Baker Hughes", "Halliburton", "Schlumberger")
diff --git a/api/src/controllers/products.py b/api/src/controllers/products.py
index 743abed..5ed4c73 100644
--- a/api/src/controllers/products.py
+++ b/api/src/controllers/products.py
@@ -1,8 +1,10 @@
+from azure.common import AzureConflictHttpError
from cachetools import TTLCache, cached
from flask import Response
+from calculators.fraction_interpolator import fraction_interpolator_and_extrapolator
from config import Config
-from util.azure_table import get_service
+from util.azure_table import get_table_service, sanitize_row_key
def sort_products(products: dict[str, dict]):
@@ -14,7 +16,11 @@ def sort_products(products: dict[str, dict]):
@cached(cache=TTLCache(maxsize=128, ttl=300))
def products_get():
- products = get_service().query_entities(Config.PRODUCT_TABLE_NAME)
+ table_service = get_table_service()
+
+ share_point_products = table_service.query_entities(Config.PRODUCT_TABLE_NAME)
+ interpolated_products = table_service.query_entities(Config.CUSTOM_PRODUCT_TABLE)
+ products = [*share_point_products, *interpolated_products]
products_response = {}
for p in products:
@@ -36,3 +42,36 @@ def products_get():
sorted_products = sort_products(products_response)
return sorted_products
+
+
+def products_post(product_name: str, supplier_name: str, product_data: [[float, float]]) -> Response:
+ product_id = sanitize_row_key(product_name)
+
+ for p in product_data:
+ if not len(p) == 2:
+ return Response("Invalid product data. Must be two valid numbers for each line", 400)
+
+ if not isinstance(p[0], float | int) or not isinstance(p[1], float | int):
+ return Response("Invalid product data. Must be two valid numbers for each line", 400)
+
+ sizes = [p[0] for p in product_data]
+ cumulative = [p[1] for p in product_data]
+ table_entry = {
+ "PartitionKey": Config.CUSTOM_PRODUCT_TABLE,
+ "RowKey": product_id,
+ "id": product_id,
+ "title": product_name,
+ "supplier": supplier_name,
+ "cumulative": str(fraction_interpolator_and_extrapolator(sizes, cumulative)),
+ "sack_size": 25,
+ "environment": "Green",
+ "cost": 100,
+ "co2": 1000,
+ }
+
+ try:
+ get_table_service().insert_entity(Config.CUSTOM_PRODUCT_TABLE, table_entry)
+ except AzureConflictHttpError:
+ return Response("A product with that name already exists", 400)
+ products_get.cache_clear()
+ return table_entry
diff --git a/api/src/tests/test_interpolator.py b/api/src/tests/test_interpolator.py
index acd73e2..a6be060 100644
--- a/api/src/tests/test_interpolator.py
+++ b/api/src/tests/test_interpolator.py
@@ -1,6 +1,6 @@
import unittest
-from calculators.fraction_interpolator import fraction_interpolator
+from calculators.fraction_interpolator import fraction_interpolator_and_extrapolator
class InterpolatorTest(unittest.TestCase):
@@ -39,6 +39,107 @@ def test_interpolator():
35.88136905,
]
- b_x = [39.8, 60.2, 104.7]
- b_y = fraction_interpolator(x=a_x, y=a_y, z=b_x)
- assert b_y == [0.214, 1.464, 16.634]
+ b_y = fraction_interpolator_and_extrapolator(xArray=a_x, yArray=a_y)
+ assert b_y == [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0.192,
+ 0.229,
+ 0.496,
+ 0.875,
+ 1.376,
+ 2.122,
+ 4.313,
+ 8.304,
+ 13.633,
+ 19.459,
+ 25.537,
+ 30.036,
+ 32.986,
+ 34.748,
+ 35.978,
+ 37.233,
+ 38.454,
+ 39.729,
+ 40.968,
+ 42.215,
+ 43.449,
+ 44.698,
+ 45.938,
+ 47.186,
+ 48.422,
+ 49.668,
+ 50.913,
+ 52.167,
+ 53.403,
+ 54.638,
+ 55.914,
+ 57.149,
+ 58.385,
+ 59.646,
+ 60.871,
+ 62.119,
+ 63.366,
+ ]
diff --git a/api/src/util/authentication.py b/api/src/util/authentication.py
index 3c6d191..ddbdb1b 100644
--- a/api/src/util/authentication.py
+++ b/api/src/util/authentication.py
@@ -45,6 +45,8 @@ def decode_jwt(token):
def authorize(f):
@wraps(f)
def wrap(*args, **kwargs):
+ if Config.AUTH_DISABLED == "true":
+ return f(*args, **kwargs)
if "Authorization" not in request.headers:
abort(401, "Missing 'Authorization' header")
try:
diff --git a/api/src/util/azure_table.py b/api/src/util/azure_table.py
index a5e5709..fd6583f 100644
--- a/api/src/util/azure_table.py
+++ b/api/src/util/azure_table.py
@@ -47,7 +47,7 @@ def sanitize_row_key(value: str) -> str:
return value.replace("/", "-").replace("\\", "-").replace("#", "").replace("?", "-").replace(" ", "").lower()
-def get_service():
+def get_table_service() -> TableService:
return TableService(account_name=Config.TABLE_ACCOUNT_NAME, account_key=Config.TABLE_KEY)
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 2ce7ca4..c334c4d 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -15,6 +15,7 @@ services:
restart: unless-stopped
environment:
ENVIRONMENT: development
+ AUTH_DISABLED: "true"
FLASK_DEBUG: "true"
TABLE_KEY: ${TABLE_KEY}
ports:
diff --git a/web/package.json b/web/package.json
index f4f5133..c9b27e2 100644
--- a/web/package.json
+++ b/web/package.json
@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
- "@equinor/eds-core-react": "^0.34.0",
- "@equinor/eds-icons": "^0.19.3",
+ "@equinor/eds-core-react": "^0.36.1",
+ "@equinor/eds-icons": "^0.21.0",
"@equinor/eds-tokens": "^0.9.2",
"axios": "^1.6.2",
"react": "^18.2.0",
@@ -28,7 +28,7 @@
"source-map-explorer": "^2.5.3"
},
"scripts": {
- "start": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts start",
+ "start": "export NODE_OPTIONS=--openssl-legacy-provider && WDS_SOCKET_PORT=80 react-scripts start",
"build": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
diff --git a/web/src/Api.ts b/web/src/Api.ts
index 4944843..e432e75 100644
--- a/web/src/Api.ts
+++ b/web/src/Api.ts
@@ -1,4 +1,5 @@
import axios from 'axios'
+import { TNewProduct } from './Types'
const BASE_PATH = '/api'
@@ -39,6 +40,9 @@ class ProductsApi {
async getProductsApi(token: string) {
return axios.get(`${BASE_PATH}/products`, { headers: { Authorization: `Bearer ${token}` } })
}
+ async postProductsApi(token: string, newProduct: TNewProduct) {
+ return axios.post(`${BASE_PATH}/products`, newProduct, { headers: { Authorization: `Bearer ${token}` } })
+ }
}
class FractionsApi {
diff --git a/web/src/Components/Bridging/Graphs/BridgeGraph.tsx b/web/src/Components/Bridging/Graphs/BridgeGraph.tsx
index f7efea9..8952d92 100644
--- a/web/src/Components/Bridging/Graphs/BridgeGraph.tsx
+++ b/web/src/Components/Bridging/Graphs/BridgeGraph.tsx
@@ -15,14 +15,12 @@ type BridgeGraphProps = {
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
- console.log(payload)
- console.log(label)
return (
{`Particle size : ${label}µm`}
- {payload.map((graphData: any) => (
-
{`${graphData.name}: ${graphData.value}%`}
+ {payload.map((graphData: any, index: number) => (
+
{`${graphData.name}: ${graphData.value}%`}
))}
diff --git a/web/src/Components/Bridging/InputContainer.tsx b/web/src/Components/Bridging/InputContainer.tsx
index dee2bb2..41a90c9 100644
--- a/web/src/Components/Bridging/InputContainer.tsx
+++ b/web/src/Components/Bridging/InputContainer.tsx
@@ -139,7 +139,7 @@ const InputContainer = ({
Optimal Bridge:
{[10, 50, 90].map(d => (
-
+
D{d}: {findDValue(optimalBridgeGraphData, d, 'Bridge')}
{'\u00B5m'}
diff --git a/web/src/Components/Combinations/CombinationCard.tsx b/web/src/Components/Combinations/CombinationCard.tsx
index 0ef043a..f4f65e5 100644
--- a/web/src/Components/Combinations/CombinationCard.tsx
+++ b/web/src/Components/Combinations/CombinationCard.tsx
@@ -1,16 +1,15 @@
import React, { useContext, useEffect, useState } from 'react'
-// @ts-ignore
-import { Button, Icon, Switch, Input, Tooltip, Divider } from '@equinor/eds-core-react'
+import { Button, Icon, Switch, Input, Tooltip } from '@equinor/eds-core-react'
import CombinationTable from './CombinationTable'
import styled from 'styled-components'
import { Card } from './CardContainer'
import { Bridge, Combination, GraphData, Product, Products } from '../../Types'
import EditProducts from '../Common/EditProducts'
import { CombinationAPI } from '../../Api'
-import { ErrorToast } from '../Common/Toast'
import { findDValue, findGraphData } from '../../Utils'
import { IAuthContext, AuthContext } from 'react-oauth2-code-pkce'
-import { edit, close, delete_to_trash } from '@equinor/eds-icons'
+import { edit, delete_to_trash } from '@equinor/eds-icons'
+import { toast } from 'react-toastify'
const CardHeader = styled.div`
display: flex;
@@ -82,6 +81,11 @@ export const CombinationCard = ({
let newMass: number = 0
let newDensity: number = 0
Object.values(combination.values).forEach(prod => {
+ if (!allProducts[prod.id]) {
+ console.error(`Product with id '${prod.id}' not found in allProducts`)
+ toast.error(`Product with id '${prod.id}' not found. Try resetting the application data.`, { autoClose: false })
+ return
+ }
newMass += allProducts[prod.id].sackSize * prod.value
newDensity += prod.value
})
@@ -101,8 +105,8 @@ export const CombinationCard = ({
setD90(findDValue(graphData, 90, combination.name))
})
.catch(error => {
- ErrorToast(`${error.response.data}`, error.response.status)
console.error('fetch error' + error)
+ toast.error(`Failed to calculate bridge for combination '${combination.name}'`, { autoClose: false })
})
}, [combination, allProducts])
diff --git a/web/src/Components/Common/ErrorBoundary.tsx b/web/src/Components/Common/ErrorBoundary.tsx
new file mode 100644
index 0000000..55070d7
--- /dev/null
+++ b/web/src/Components/Common/ErrorBoundary.tsx
@@ -0,0 +1,57 @@
+import { Icon, List, Typography } from '@equinor/eds-core-react'
+import { account_circle } from '@equinor/eds-icons'
+import { Component, ErrorInfo, ReactNode } from 'react'
+
+interface Props {
+ children?: ReactNode
+}
+
+interface State {
+ hasError: boolean
+}
+
+class ErrorBoundary extends Component
{
+ constructor(props) {
+ super(props)
+ this.state = { hasError: false }
+ }
+ // public state: State = {
+ // hasError: false,
+ // }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ static getDerivedStateFromError(_: Error): State {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true }
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('Uncaught error:', error)
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+ <>
+ Ops... Something went wrong 😞
+ You should try the following:
+
+ Refresh the page (F5)
+
+ Resetting the application by clicking the icon, and then{' '}
+ "reset application data".
+
+
+ Contact technical support by email at{' '}
+ fg_team_hermes@equinor.com
+
+
+ >
+ )
+ }
+
+ return this.props.children
+ }
+}
+
+export default ErrorBoundary
diff --git a/web/src/Components/Common/Toast.tsx b/web/src/Components/Common/Toast.tsx
index 4dac491..70cb1df 100644
--- a/web/src/Components/Common/Toast.tsx
+++ b/web/src/Components/Common/Toast.tsx
@@ -1,8 +1,6 @@
import { toast } from 'react-toastify'
import React from 'react'
-export const InfoToast = (msg: string) => toast.info(msg)
-export const WarningToast = (msg: string) => toast.warning(msg)
export const ErrorToast = (msg: string, code?: number): any => {
const title = Error
const config: Object = { autoClose: 7000 }
diff --git a/web/src/Components/Navbar/ContactButton.tsx b/web/src/Components/Navbar/ContactButton.tsx
index 8be1671..3108881 100644
--- a/web/src/Components/Navbar/ContactButton.tsx
+++ b/web/src/Components/Navbar/ContactButton.tsx
@@ -7,11 +7,11 @@ export const ContactButton = () => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
- <>
-