Skip to content

Commit

Permalink
feat: create product UI
Browse files Browse the repository at this point in the history
  • Loading branch information
soofstad committed Apr 10, 2024
1 parent cc94c05 commit 0a79a54
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 94 deletions.
20 changes: 0 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
4 changes: 0 additions & 4 deletions api/src/calculators/fraction_interpolator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

def find_closest_bigger_index(array: list[float], target: float) -> int:
index = bisect_left(array, target)
if index == 0:
raise ValueError("Failed to find closest biggest index")
return index + 1


Expand All @@ -22,8 +20,6 @@ def fraction_interpolator_and_extrapolator(
) -> 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
s = sum(yArray)
print(f"Sum Y: {s}")

for zIndex, z in enumerate(zArray[starting_index:]):
if z < xArray[0]: # Don't extrapolate down from first measuring point
Expand Down
14 changes: 13 additions & 1 deletion api/src/controllers/products.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from azure.common import AzureConflictHttpError
from cachetools import TTLCache, cached
from flask import Response

Expand Down Expand Up @@ -45,6 +46,14 @@ def products_get():

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 = {
Expand All @@ -60,6 +69,9 @@ def products_post(product_name: str, supplier_name: str, product_data: [[float,
"co2": 1000,
}

get_table_service().insert_entity(Config.CUSTOM_PRODUCT_TABLE, table_entry)
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
109 changes: 105 additions & 4 deletions api/src/tests/test_interpolator.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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,
]
4 changes: 2 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions web/src/Api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios'
import { TNewProduct } from './Types'

const BASE_PATH = '/api'

Expand Down Expand Up @@ -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 {
Expand Down
149 changes: 149 additions & 0 deletions web/src/Components/Navbar/CreateProduct.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React, { useContext, useState } from 'react'
// @ts-ignore
import { Button, Dialog, Icon, TextField, Table } from '@equinor/eds-core-react'

import { ProductsAPI } from '../../Api'
import styled from 'styled-components'
import { ErrorToast } from '../Common/Toast'
import { AuthContext } from 'react-oauth2-code-pkce'
import { IAuthContext } from 'react-oauth2-code-pkce'
import { upload } from '@equinor/eds-icons'
import { TNewProduct } from '../../Types'
import { toast } from 'react-toastify'

const ButtonWrapper = styled.div`
display: flex;
justify-content: space-between;
width: 100%;
`
const parseCumulativeProductCurve = (curve: string): number[][] => {
// Split the input string into lines using the newline character
const lines = curve.split('\n')

// Map each line into an array of two elements
const parsedData = lines.map(line => {
// Replace commas with periods to handle European-style decimals
const cleanLine = line.replace(/,/g, '.')
// Split each line by spaces or tabs to separate the numbers
const elements = cleanLine.split(/\s+|\t+/)
// Convert the string elements to numbers
return elements.map(element => parseFloat(element))
})

return parsedData
}

export const RefreshButton = () => {
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const { token }: IAuthContext = useContext(AuthContext)
const [newProduct, setNewProduct] = useState<TNewProduct>()
const [newProductData, setNewProductData] = useState<number[][]>([])

const postProduct = () => {
ProductsAPI.postProductsApi(token, { ...newProduct, productData: newProductData })
.then(() => {
setDialogOpen(false)
setLoading(false)
toast.success('Product created. Reloading page...')
setTimeout(() => window.location.reload(), 2000)
})
.catch(error => {
ErrorToast(`${error.response.data}`, error.response.status)
console.error('fetch error' + error)
setLoading(false)
})
}

return (
<>
<Button variant='outlined' onClick={() => setDialogOpen(true)}>
<Icon data={upload} title='refresh' />
Create product
</Button>
<Dialog open={dialogOpen} isDismissable={true} style={{ width: 'min-content' }}>
<Dialog.Header>
<Dialog.Title>Define a new product</Dialog.Title>
</Dialog.Header>
<Dialog.CustomContent style={{ display: 'flex', flexFlow: 'column', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', padding: '0px', alignSelf: 'start' }}>
<TextField
style={{ padding: '10px 0' }}
id='name'
label='Product name'
value={newProduct?.productName ?? ''}
onChange={event => setNewProduct({ ...newProduct, productName: event.target.value })}
/>
<TextField
style={{ padding: '10px 0' }}
id='supplier'
label='Supplier name'
value={newProduct?.productSupplier ?? ''}
onChange={event => setNewProduct({ ...newProduct, productSupplier: event.target.value })}
/>
</div>
<div>
<p>
Paste the product's measured data values here. Make sure it's been parsed correctly by inspecting the
table below before submitting.
</p>
<p>
The format of the pasted data should be two numbers on each line (space or tab separated), where the first
number is the fraction size in micron of the measuring point, and the other the cumulative volume
percentage.
</p>
<p>
The Optimizer requires each product to have 100 data points, from 0.01 - 3500 microns. If the data you
provide is missing data, the values will be interpolated and extrapolated.
</p>
<TextField
id='data'
style={{ width: '500px', overflow: 'auto' }}
placeholder='Paste the cumulative curve here'
multiline
rows={6}
label='Cumulative fractions data'
onChange={event => setNewProductData(parseCumulativeProductCurve(event.target.value))}
/>
</div>
<div style={{ maxHeight: '300px', overflow: 'auto', marginTop: '20px' }}>
<Table>
<Table.Head>
<Table.Row>
<Table.Cell>Index</Table.Cell>
<Table.Cell>Fraction(micron)</Table.Cell>
<Table.Cell>Cumulative</Table.Cell>
</Table.Row>
</Table.Head>
{newProductData.map((dataPoint: any, index) => (
<Table.Row key={index}>
<Table.Cell>{index} </Table.Cell>
<Table.Cell>{dataPoint[0]} </Table.Cell>
<Table.Cell>{dataPoint[1]} </Table.Cell>
</Table.Row>
))}
</Table>
</div>
</Dialog.CustomContent>
<Dialog.Actions style={{ width: 'fill-available', display: 'flex', justifySelf: 'normal' }}>
<ButtonWrapper>
<Button variant='outlined' onClick={() => setDialogOpen(false)} disabled={loading}>
Cancel
</Button>
<Button
disabled={loading || !newProductData || !newProduct?.productSupplier || !newProduct?.productName}
onClick={() => {
setLoading(true)
postProduct()
}}
>
Create
</Button>
</ButtonWrapper>
</Dialog.Actions>
</Dialog>
</>
)
}

export default RefreshButton
4 changes: 3 additions & 1 deletion web/src/Components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import RefreshButton from './RefreshButton'
import { ContactButton } from './ContactButton'
import { info_circle } from '@equinor/eds-icons'
import { StyledLink } from './styles'
import CreateProduct from './CreateProduct'

const Navbar = () => {
return (
Expand All @@ -14,7 +15,7 @@ const Navbar = () => {
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, fit-content(100%))',
gridTemplateColumns: 'repeat(4, fit-content(100%))',
gap: '16px',
}}
>
Expand All @@ -30,6 +31,7 @@ const Navbar = () => {
</Button>
</StyledLink>
</div>
<CreateProduct />
<RefreshButton />
<div>
<ContactButton />
Expand Down
Loading

0 comments on commit 0a79a54

Please sign in to comment.