<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Oploy Notes</title><description>AI and science experiments, with what worked and what didn&apos;t.</description><link>https://notes.oploy.eu/</link><item><title>My old laptop became a Linux server</title><link>https://notes.oploy.eu/old-laptop-linux-server/</link><guid isPermaLink="true">https://notes.oploy.eu/old-laptop-linux-server/</guid><description>What I learned running websites, APIs, databases and small experiments on an 8 GB Core i5 laptop, managed from Windows.</description><pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I had an older Huawei laptop with 8 GB of RAM and a Core i5 processor. I installed Ubuntu Server on it, and gradually moved useful services onto it. It now stays on day and night while I work from my Windows laptop.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I started this setup in September 2026. The service inventory and RAM measurements below were checked in October 2026.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There was no big plan to build a home data centre. I wanted to see how much useful work a computer I already owned could do.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-2c5c2501c695.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-2c5c2501c695.svg&quot; alt=&quot;Windows manages the Ubuntu laptop through SSH. Public visitors reach its applications through Cloudflare Tunnel, while applications use their own databases.&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-actually-runs-on-it&quot;&gt;What actually runs on it&lt;/h2&gt;
&lt;p&gt;The processor is an &lt;strong&gt;Intel Core i5-10210U&lt;/strong&gt;, with four cores and eight threads. Ubuntu reports about &lt;strong&gt;7.5 GiB of usable RAM&lt;/strong&gt;. On 8 October 2026, the machine had been up for &lt;strong&gt;28 days and nine hours&lt;/strong&gt;, with &lt;strong&gt;28 running Docker containers&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here is one RAM snapshot, taken at about 23:36 Amsterdam time that evening. Each application row includes its supporting containers, including its database where applicable. MiB means mebibytes.&lt;/p&gt;













































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Service&lt;/th&gt;&lt;th&gt;Public address&lt;/th&gt;&lt;th&gt;What I use it for&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;RAM in this snapshot&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Oploy website&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://www.oploy.eu/&quot;&gt;www.oploy.eu&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Django/Wagtail website and PostgreSQL&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;147 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Oploy Notes&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://notes.oploy.eu/&quot;&gt;notes.oploy.eu&lt;/a&gt;&lt;/td&gt;&lt;td&gt;This static notebook, served by nginx&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JobLab&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://or.oploy.eu/dashboard&quot;&gt;or.oploy.eu/dashboard&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Operations research job dashboard, PostgreSQL, PostgREST and API proxy&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;198 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;AI engineering jobs&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://aieng.oploy.eu/dashboard&quot;&gt;aieng.oploy.eu/dashboard&lt;/a&gt;&lt;/td&gt;&lt;td&gt;AI, ML and data science job dashboard and its own database stack&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;163 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Lang App&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://lang.oploy.eu/&quot;&gt;lang.oploy.eu&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Language learning, audio processing, API, web app, PostgreSQL, Redis and proxy&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;681 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JobPilot personal search backend&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://jobpilot.oploy.eu/en/app/my-job-search&quot;&gt;JobPilot My Job Search&lt;/a&gt;&lt;br&gt;API: &lt;a href=&quot;https://jobsearch-api.oploy.eu/health&quot;&gt;jobsearch-api.oploy.eu/health&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Search API, PostgreSQL, PostgREST and proxy&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;170 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Inventory simulation&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://siminv.netlify.app/&quot;&gt;siminv.netlify.app&lt;/a&gt;&lt;br&gt;API: &lt;a href=&quot;https://api-siminv.oploy.eu/health&quot;&gt;api-siminv.oploy.eu/health&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Run inventory simulations&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;18 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Inventory optimization&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://simopt.netlify.app/&quot;&gt;simopt.netlify.app&lt;/a&gt;&lt;br&gt;API: &lt;a href=&quot;https://api-simopt.oploy.eu/health&quot;&gt;api-simopt.oploy.eu/health&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Search for inventory policies using simulation&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;50 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Repair-shop scheduling&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://opt.oploy.eu/&quot;&gt;opt.oploy.eu&lt;/a&gt;&lt;br&gt;API: &lt;a href=&quot;https://api-scheduling.oploy.eu/&quot;&gt;api-scheduling.oploy.eu&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Optimization API for scheduling experiments&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;70 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Self-hosted Convex stack&lt;/td&gt;&lt;td&gt;Used by &lt;a href=&quot;https://jobpilot.oploy.eu/&quot;&gt;JobPilot&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Backend, database and dashboard; JobPilot’s frontend is hosted elsewhere&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;693 MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Portainer&lt;/td&gt;&lt;td&gt;Private administration&lt;/td&gt;&lt;td&gt;Manage Docker containers, logs, images and volumes&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;21 MiB&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;For the three optimization projects and JobPilot, the website itself is hosted elsewhere (Netlify for the optimization apps). The laptop runs the API behind it, which is why each row has two links.&lt;/p&gt;
&lt;p&gt;The scraper runs on a schedule, so its temporary container is not included in these always-running figures. Cloudflare Tunnel is a host service and used about 42 MiB of resident memory separately.&lt;/p&gt;
&lt;p&gt;These are &lt;strong&gt;observed use, not memory limits or peak requirements&lt;/strong&gt;. Audio work, scraping and optimization can need much more. The language worker was active during the measurement, so this is not a strict idle benchmark.&lt;/p&gt;
&lt;p&gt;The host reported about &lt;strong&gt;3.2 GiB used and 4.3 GiB available&lt;/strong&gt;, with about &lt;strong&gt;0.82 GiB in swap&lt;/strong&gt;. Linux also uses RAM for caches. Container figures do not add up to the whole operating system’s memory use.&lt;/p&gt;
&lt;h2 id=&quot;a-separate-place-to-run-things&quot;&gt;A separate place to run things&lt;/h2&gt;
&lt;p&gt;One benefit is simple: I can close my Windows laptop and the services keep running. My working computer and the server have different jobs.&lt;/p&gt;
&lt;p&gt;Docker gives each application its own dependencies, network and persistent storage. A Python virtual environment is also useful for a small script. I can try a deployment without filling my Windows machine with another database and another background process.&lt;/p&gt;
&lt;p&gt;This is the separation I wanted from a virtual environment. &lt;strong&gt;Containers share the Linux kernel; they are not full virtual machines.&lt;/strong&gt; An experiment can still exhaust the host unless I set limits. For stronger isolation, I would use a VM and budget extra RAM for it.&lt;/p&gt;
&lt;p&gt;Ubuntu Server does not run a desktop interface on this machine. That leaves more room for the services, but I did not measure the same workload on Windows before the move, so I cannot give an honest RAM-saving percentage.&lt;/p&gt;
&lt;p&gt;It feels responsive for my small workloads. A recorded language-app test processed a roughly three-minute audio fixture in about 28 seconds with four CPU threads. That is one specific test, not a promise that an older laptop can handle every AI workload.&lt;/p&gt;
&lt;h2 id=&quot;managing-it-from-windows&quot;&gt;Managing it from Windows&lt;/h2&gt;
&lt;p&gt;Most of the time I open Windows Terminal, connect over SSH and deploy there. I build an image, start a container, read its logs and check its health endpoint. I do not need to sit at the server’s keyboard.&lt;/p&gt;
&lt;p&gt;For a visual overview, I use Cockpit for the operating system and Portainer for Docker. Seeing memory, logs and services in a browser makes small problems easier to understand.&lt;/p&gt;
&lt;p&gt;I also added two Cockpit pages for the laptop hardware:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Battery:&lt;/strong&gt; charge percentage, AC connection, battery health, cycle count and current power draw. It refreshes every five seconds while visible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Display:&lt;/strong&gt; brightness, screen on/off and the font size on the physical console. These controls need administrative access.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Battery monitoring and charging control are separate. A boot service applies charging thresholds: start below &lt;strong&gt;75%&lt;/strong&gt;, stop at &lt;strong&gt;80%&lt;/strong&gt;. The live reading was 79%, with the battery not charging. The dashboard makes the state visible; it does not enforce those thresholds.&lt;/p&gt;
&lt;p&gt;I also changed lid and power-button behaviour, and added a lid hook that blanks the display without suspending the server. A third-party Docker Manager page gives me basic container controls inside Cockpit. Portainer remains useful for deeper management.&lt;/p&gt;
&lt;h2 id=&quot;moving-services-from-railway-and-aws&quot;&gt;Moving services from Railway and AWS&lt;/h2&gt;
&lt;p&gt;I moved the Wagtail website and its PostgreSQL database from Railway on &lt;strong&gt;11 September 2026&lt;/strong&gt;. The application runs locally in Docker, and visitors reach it through Cloudflare Tunnel. Website media still lives in Cloudflare R2.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://notes.oploy.eu/postgresql-railway/&quot;&gt;earlier Railway database note&lt;/a&gt; describes the setup I moved from.&lt;/p&gt;
&lt;p&gt;For JobLab, a systemd timer took over the scheduled scraper job from AWS. Its data now goes through duplicate checks into local PostgreSQL and the dashboard. The deployment record says the AWS schedule was disabled after verification.&lt;/p&gt;
&lt;p&gt;I described the original cloud version in the &lt;a href=&quot;https://notes.oploy.eu/job-scraper-aws-fargate/&quot;&gt;AWS Fargate scraper note&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;JobPilot’s personal search backend also moved from Lambda to the server. That required adapting the application and changing frontend endpoints. Moving from serverless hosting involves more than copying a function file.&lt;/p&gt;
&lt;p&gt;I did not move every cloud dependency. This is a mixture of local services and external services, which suits me better than making everything local just for the sake of it.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong-and-what-i-still-have-to-own&quot;&gt;What went wrong, and what I still have to own&lt;/h2&gt;
&lt;p&gt;Closing a lid must not suspend a server. The default short power-button press also shut this machine down unexpectedly, so I changed it. Keeping the screen dark and leaving the cooling vents clear matter too.&lt;/p&gt;
&lt;p&gt;The battery limit was another lesson. The Huawei driver showed the thresholds I had written, but firmware smart-charge mode initially ignored them. I had to verify actual charging behaviour rather than trust a setting that looked correct.&lt;/p&gt;
&lt;p&gt;I reused hardware, so the additional hardware cost was zero. Electricity, storage, backups, domain fees, external APIs and my maintenance time still count. I have not measured total monthly savings against AWS and Railway.&lt;/p&gt;
&lt;p&gt;Running 24 hours a day is my operating choice, not an availability guarantee. Internet outages, a failed disk or a stopped tunnel can take these applications offline. The battery can keep the laptop alive briefly; it cannot keep the building’s router online.&lt;/p&gt;
&lt;p&gt;For small applications and learning, I am happy with this tradeoff. For something that needs stronger availability, I would choose hosting that matches that requirement.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;I wrote a separate &lt;a href=&quot;https://notes.oploy.eu/old-laptop-linux-server-guide/&quot;&gt;step-by-step guide&lt;/a&gt; covering installation, Windows SSH access, a first Docker service, migration, and the Cockpit additions. Screenshots of the laptop and dashboard can follow later.&lt;/p&gt;
&lt;p&gt;If you have a spare laptop, try one small service first. Check its memory, leave it running, and learn how to restore it. You do not have to move everything to find out whether this is useful for you.&lt;/p&gt;</content:encoded></item><item><title>How to turn a spare laptop into a Linux server</title><link>https://notes.oploy.eu/old-laptop-linux-server-guide/</link><guid isPermaLink="true">https://notes.oploy.eu/old-laptop-linux-server-guide/</guid><description>The setup I use: Ubuntu Server, SSH from Windows, Docker, Cloudflare Tunnel, and a Cockpit dashboard with battery and display pages.</description><pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My &lt;a href=&quot;https://notes.oploy.eu/old-laptop-linux-server/&quot;&gt;experience note&lt;/a&gt; describes what runs on my 8 GB Core i5 laptop. This guide explains how to build a similar setup, starting with one service and adding the laptop-specific pieces afterward.&lt;/p&gt;
&lt;p&gt;The commands below are examples for a fresh Ubuntu Server 24.04 installation. Replace the username, address, domain and application paths with your own. Hardware-specific steps need checking on your laptop.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-de0e41fb3ef3.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-de0e41fb3ef3.svg&quot; alt=&quot;Install Ubuntu, connect from Windows with SSH, run a local container, add a public tunnel, and then migrate one application at a time with backups and checks.&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;1-prepare-the-laptop&quot;&gt;1. Prepare the laptop&lt;/h2&gt;
&lt;p&gt;Back up the files you want to keep before installing Linux. Installation can erase the selected disk. I used the server edition of Ubuntu without a desktop interface.&lt;/p&gt;
&lt;p&gt;Download the installation image (ISO) from the &lt;a href=&quot;https://ubuntu.com/download/server&quot;&gt;official Ubuntu Server website&lt;/a&gt;. My setup uses Ubuntu Server &lt;strong&gt;24.04 LTS&lt;/strong&gt;. To follow the same version, look under previous releases if the main download button offers a newer release.&lt;/p&gt;
&lt;p&gt;I used &lt;a href=&quot;https://rufus.ie/&quot;&gt;Rufus on Windows&lt;/a&gt; to prepare a hard disk as bootable installation media. It worked, but for a first attempt I would use a &lt;strong&gt;USB flash drive&lt;/strong&gt;. It makes it easier to keep the installer separate from the laptop’s disk where Ubuntu will be installed.&lt;/p&gt;
&lt;p&gt;In Rufus, select the drive you intend to use as installation media, choose the downloaded ISO, and write the image. Preparing that drive can erase its contents, so check the selected device carefully. Then boot the laptop from that drive using its boot menu and follow the Ubuntu installer.&lt;/p&gt;
&lt;p&gt;My installation was not completely smooth. I ran into errors while choosing file locations and a few other setup problems. I worked through them and had them resolved within a day. If your first attempt needs some troubleshooting, that does not mean you cannot do this. Take one problem at a time, and double-check the selected disk before confirming storage changes.&lt;/p&gt;
&lt;p&gt;Use a working charger, sound battery and ventilated surface. A swollen battery needs replacing before continuous use. Ethernet is useful if available; mine uses Wi-Fi, which adds another dependency to availability.&lt;/p&gt;
&lt;p&gt;During installation, create a normal user and select OpenSSH server. Once the machine has network access, check it at the physical keyboard:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip -br address
lscpu
free -h
df -h
sudo apt update
sudo apt upgrade
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If OpenSSH was not installed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install openssh-server
sudo systemctl enable --now ssh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ubuntu documents the installation and configuration in its &lt;a href=&quot;https://ubuntu.com/server/docs/openssh-server/&quot;&gt;OpenSSH server guide&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;2-connect-from-windows-terminal&quot;&gt;2. Connect from Windows Terminal&lt;/h2&gt;
&lt;p&gt;In PowerShell, use the address shown on the Ubuntu laptop:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;ssh your-user@YOUR_SERVER_ADDRESS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the first connection, compare the host-key fingerprint with the server before accepting it. You can display the server’s ED25519 fingerprint at its own keyboard:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a Windows SSH key if you do not already have one. Keep the private key on Windows; only its &lt;code&gt;.pub&lt;/code&gt; file goes to the server. A passphrase protects the key if the file is copied.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;ssh-keygen -t ed25519
Get-Content &quot;$env:USERPROFILE\.ssh\id_ed25519.pub&quot; | ssh your-user@YOUR_SERVER_ADDRESS &quot;umask 077; mkdir -p ~/.ssh; cat &gt;&gt; ~/.ssh/authorized_keys&quot;
ssh your-user@YOUR_SERVER_ADDRESS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a shorter command, add an entry to your Windows user SSH config, &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Host spare-server
    HostName YOUR_SERVER_ADDRESS
    User your-user
    IdentityFile ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then &lt;code&gt;ssh spare-server&lt;/code&gt; opens the Linux terminal. The server keeps running when that terminal closes. A DHCP reservation helps keep its address stable if you control the router.&lt;/p&gt;
&lt;h2 id=&quot;3-keep-the-laptop-awake&quot;&gt;3. Keep the laptop awake&lt;/h2&gt;
&lt;p&gt;On Ubuntu, create a logind drop-in rather than modifying the main configuration file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /etc/systemd/logind.conf.d
sudo tee /etc/systemd/logind.conf.d/10-laptop-server.conf &gt;/dev/null &amp;#x3C;&amp;#x3C;&apos;EOF&apos;
[Login]
HandlePowerKey=ignore
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
EOF
sudo systemctl reload systemd-logind
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the effective setting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;busctl get-property org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager HandleLidSwitch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It should report &lt;code&gt;ignore&lt;/code&gt;. Close the lid and confirm that SSH still works. Keep the vents clear; some laptops cool poorly with the lid closed. Shut down deliberately with &lt;code&gt;sudo systemctl poweroff&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;My own configuration also requests a clean shutdown on a long power-button press. Firmware can cut power before the software reacts, so I do not rely on that as a safe shutdown method.&lt;/p&gt;
&lt;p&gt;Ignoring the lid does not necessarily turn the panel off. My separate &lt;code&gt;acpid&lt;/code&gt; hook reads the lid state and writes the panel’s &lt;code&gt;bl_power&lt;/code&gt; setting. The screen behaviour depends on hardware, so test screen control separately before automating it.&lt;/p&gt;
&lt;h2 id=&quot;4-install-docker-and-run-one-service&quot;&gt;4. Install Docker and run one service&lt;/h2&gt;
&lt;p&gt;Follow &lt;a href=&quot;https://docs.docker.com/engine/install/ubuntu/&quot;&gt;Docker’s Ubuntu installation guide&lt;/a&gt; to add its signed APT repository. After that repository is configured:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run --rm hello-world
sudo docker compose version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is a small first service. On the server, create &lt;code&gt;~/first-service/compose.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  web:
    image: nginx:stable-alpine
    restart: unless-stopped
    ports:
      - &quot;127.0.0.1:8080:80&quot;
    mem_limit: 128m
    cpus: 0.5
    logging:
      driver: json-file
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start and check it from that directory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo docker compose up -d
curl -I http://127.0.0.1:8080
sudo docker compose logs --tail=30
sudo docker stats --no-stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The loopback binding keeps the example off the LAN. From Windows, forward it through SSH:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;ssh -N -L 8080:127.0.0.1:8080 spare-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While that connection stays open, visit &lt;code&gt;http://localhost:8080&lt;/code&gt; in your Windows browser. You should see nginx’s welcome page. The image tag follows a release channel; pin a tested digest when you need a reproducible deployment.&lt;/p&gt;
&lt;p&gt;For a real app, replace the image, add the correct environment and health check, and give databases named volumes. Set memory limits based on measured peaks. Several 2 GiB limits do not mean the host has enough RAM to satisfy them simultaneously.&lt;/p&gt;
&lt;h2 id=&quot;5-give-a-service-a-public-address&quot;&gt;5. Give a service a public address&lt;/h2&gt;
&lt;p&gt;I use Cloudflare Tunnel, which connects outward from the server. I did not need an inbound router port-forward for the website. A tunnel provides the route; the application still needs its own authentication where appropriate.&lt;/p&gt;
&lt;p&gt;Follow Cloudflare’s &lt;a href=&quot;https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/&quot;&gt;locally managed tunnel instructions&lt;/a&gt; to install &lt;code&gt;cloudflared&lt;/code&gt;, log in, create a tunnel and protect its credential file. My deployment uses local configuration; Cloudflare also offers remotely managed tunnels.&lt;/p&gt;
&lt;p&gt;For a fresh named tunnel, the creation commands are:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cloudflared tunnel login
cloudflared tunnel create spare-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An illustrative configuration is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;tunnel: YOUR_TUNNEL_ID
credentials-file: /etc/cloudflared/YOUR_TUNNEL_ID.json
ingress:
  - hostname: demo.example.com
    service: http://127.0.0.1:8080
  - service: http_status:404
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The credential path must match where you securely installed the generated file. Add the hostname under a domain you control:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cloudflared tunnel route dns spare-server demo.example.com
sudo cloudflared tunnel --config /etc/cloudflared/config.yml ingress validate
sudo cloudflared --config /etc/cloudflared/config.yml service install
sudo systemctl enable --now cloudflared
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These service-install commands are for a new setup. An existing tunnel should be backed up and edited without reinstalling its service. Test the local endpoint first, then the public hostname. Cloudflare explains rule order and the final catch-all in its &lt;a href=&quot;https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/&quot;&gt;configuration reference&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;6-add-cockpit-and-the-laptop-pages&quot;&gt;6. Add Cockpit and the laptop pages&lt;/h2&gt;
&lt;p&gt;Cockpit gives me system metrics, services, logs, storage and a terminal. On my Ubuntu 24.04 server I installed individual components from backports:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install -t noble-backports cockpit-bridge cockpit-ws cockpit-system cockpit-storaged pcp
sudo systemctl enable --now cockpit.socket
sudo systemctl enable --now pmcd pmlogger
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This assumes the &lt;code&gt;noble-backports&lt;/code&gt; repository is enabled. I avoided adding Cockpit’s NetworkManager component because this machine already uses netplan with &lt;code&gt;systemd-networkd&lt;/code&gt; for Wi-Fi. Check the package changes before accepting them on a machine managed over SSH.&lt;/p&gt;
&lt;p&gt;Keep administration on a trusted network or VPN. You can also forward Cockpit from Windows with &lt;code&gt;ssh -N -L 9090:127.0.0.1:9090 spare-server&lt;/code&gt; and open &lt;code&gt;https://localhost:9090&lt;/code&gt;. Its default certificate is self-signed; verify the server identity before trusting it. The forward does not itself restrict the server’s listening socket.&lt;/p&gt;
&lt;h3 id=&quot;the-battery-page&quot;&gt;The Battery page&lt;/h3&gt;
&lt;p&gt;I included the actual source files from my server with this guide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-battery/manifest.json&quot;&gt;Manifest&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-battery/index.html&quot;&gt;Page HTML&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-battery/battery.js&quot;&gt;JavaScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-battery/battery.css&quot;&gt;Stylesheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-battery/install.sh&quot;&gt;Install script&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Save all five together in &lt;code&gt;~/cockpit-battery&lt;/code&gt; on your server, review the files, then run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sh ~/cockpit-battery/install.sh
cockpit-bridge --packages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script copies the package to &lt;code&gt;/usr/share/cockpit/battery/&lt;/code&gt;. Reload the Cockpit browser session; if necessary, sign out and back in. You should see &lt;strong&gt;Battery&lt;/strong&gt; in the menu.&lt;/p&gt;
&lt;p&gt;It reads &lt;code&gt;/sys/class/power_supply/&lt;/code&gt; without elevated privileges. It finds devices by their type instead of assuming &lt;code&gt;BAT0&lt;/code&gt;, supports both &lt;code&gt;energy_*&lt;/code&gt; and &lt;code&gt;charge_*&lt;/code&gt; readings, and pauses its five-second polling while hidden. It uses design voltage to convert charge capacity to watt-hours when available.&lt;/p&gt;
&lt;h3 id=&quot;the-display-page&quot;&gt;The Display page&lt;/h3&gt;
&lt;p&gt;The matching files are &lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-display/manifest.json&quot;&gt;manifest&lt;/a&gt;, &lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-display/index.html&quot;&gt;HTML&lt;/a&gt;, &lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-display/display.js&quot;&gt;JavaScript&lt;/a&gt;, &lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-display/display.css&quot;&gt;CSS&lt;/a&gt; and &lt;a href=&quot;https://notes.oploy.eu/examples/cockpit-display/install.sh&quot;&gt;installer&lt;/a&gt;. Save them in &lt;code&gt;~/cockpit-display&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Adapt &lt;code&gt;display.js&lt;/code&gt; before installing it.&lt;/strong&gt; My version uses &lt;code&gt;intel_backlight&lt;/code&gt;, &lt;code&gt;/dev/tty1&lt;/code&gt;, and &lt;code&gt;Uni2-TerminusBold&lt;/code&gt; console fonts. Check your backlight directory and available font files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ls /sys/class/backlight
ls /usr/share/consolefonts/Uni2-TerminusBold*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Change &lt;code&gt;BRIGHTNESS_PATH&lt;/code&gt;, &lt;code&gt;MAXB_PATH&lt;/code&gt;, &lt;code&gt;BLPOWER_PATH&lt;/code&gt;, &lt;code&gt;TTY&lt;/code&gt;, &lt;code&gt;FONT_PREFIX&lt;/code&gt; and &lt;code&gt;FONT_SIZES&lt;/code&gt; as needed. The page reads the actual maximum brightness, so it does not assume a 0–255 range. Then run &lt;code&gt;sh ~/cockpit-display/install.sh&lt;/code&gt; and reload Cockpit.&lt;/p&gt;
&lt;p&gt;Display writes require Cockpit’s administrative access. One detail cost me time: &lt;code&gt;cockpit.spawn()&lt;/code&gt; takes process input through &lt;code&gt;.input()&lt;/code&gt;, rather than an &lt;code&gt;input&lt;/code&gt; option:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;var proc = cockpit.spawn([&quot;tee&quot;, path], {
    superuser: &quot;require&quot;,
    err: &quot;message&quot;
});
proc.input(value);
return proc;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For sysfs, use a direct write like this. A temporary-file-and-rename operation cannot replace a sysfs attribute. Cockpit documents &lt;a href=&quot;https://cockpit-project.org/guide/latest/packages.html&quot;&gt;packages&lt;/a&gt; and &lt;a href=&quot;https://cockpit-project.org/guide/latest/cockpit-spawn.html&quot;&gt;process spawning&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I also use Portainer and a third-party &lt;a href=&quot;https://github.com/chrisjbawden/cockpit-dockermanager&quot;&gt;Docker Manager plugin&lt;/a&gt; for container management. Docker Manager is optional. Access to Docker or its socket gives extensive control of the host, so these belong behind restricted administration access.&lt;/p&gt;
&lt;h2 id=&quot;7-configure-battery-charging-separately&quot;&gt;7. Configure battery charging separately&lt;/h2&gt;
&lt;p&gt;My Battery page monitors; it does not set limits. A separate boot service applies 75% start and 80% stop thresholds through Huawei’s driver.&lt;/p&gt;
&lt;p&gt;Inspect the interfaces your hardware exposes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ls /sys/class/power_supply
find -L /sys/class/power_supply/ -maxdepth 2 -name &apos;*threshold*&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On my supported Huawei model, the paired attribute exists at the following path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /sys/devices/platform/huawei-wmi/charge_control_thresholds
printf &apos;75 80\n&apos; | sudo tee /sys/devices/platform/huawei-wmi/charge_control_thresholds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a &lt;strong&gt;Huawei-specific example&lt;/strong&gt;, not a command for every laptop. Check your driver’s documentation and firmware first. Make a persistent boot service only after a manual test works.&lt;/p&gt;
&lt;p&gt;On my machine, smart-charge firmware initially ignored these settings even though the driver read them back correctly. My boot script disables that mode before applying the pair. The &lt;a href=&quot;https://github.com/qu1x/huawei-wmi&quot;&gt;Huawei WMI project&lt;/a&gt; describes that hardware-specific behaviour; do not copy its low-level firmware calls to an unrelated model.&lt;/p&gt;
&lt;p&gt;Watch the charge percentage, AC state and current over time, then verify again after a reboot. Reading back &lt;code&gt;75 80&lt;/code&gt; alone does not prove the battery obeys it. Mine can say “Not charging” or “Discharging” on mains while above the charging range.&lt;/p&gt;
&lt;h2 id=&quot;8-move-one-cloud-application-at-a-time&quot;&gt;8. Move one cloud application at a time&lt;/h2&gt;
&lt;p&gt;Before cutting over, list the code, database, uploaded files, secrets, scheduled tasks, domain routes and external APIs. Recreate each dependency or deliberately keep it hosted. An application image does not contain its database volume.&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Previous component&lt;/th&gt;&lt;th&gt;My local replacement&lt;/th&gt;&lt;th&gt;What needs checking&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Railway Wagtail application&lt;/td&gt;&lt;td&gt;Docker application container&lt;/td&gt;&lt;td&gt;Settings, allowed hosts, migrations, secrets and local health&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Railway PostgreSQL&lt;/td&gt;&lt;td&gt;PostgreSQL container and named volume&lt;/td&gt;&lt;td&gt;Compatible version, dump/restore, data and permissions&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;AWS scheduled scraper&lt;/td&gt;&lt;td&gt;Docker job started by a systemd timer&lt;/td&gt;&lt;td&gt;Timezone, duplicate checks, logs and successful writes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Lambda personal search&lt;/td&gt;&lt;td&gt;FastAPI service and supporting database stack&lt;/td&gt;&lt;td&gt;Request contract, authentication, timeouts and frontend endpoints&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Public cloud endpoint&lt;/td&gt;&lt;td&gt;Cloudflare hostname routed to local loopback port&lt;/td&gt;&lt;td&gt;Public response, login and application behaviour&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;railway-app-and-database-together&quot;&gt;Railway: app and database together&lt;/h3&gt;
&lt;p&gt;My Wagtail move used &lt;code&gt;pg_dump -Fc&lt;/code&gt; and &lt;code&gt;pg_restore&lt;/code&gt;, with PostgreSQL 16 on both sides. I checked page counts and the latest publication timestamp before routing the public domain to the laptop.&lt;/p&gt;
&lt;p&gt;My &lt;a href=&quot;https://notes.oploy.eu/postgresql-railway/&quot;&gt;Railway PostgreSQL note&lt;/a&gt; shows the original application connection and settings.&lt;/p&gt;
&lt;p&gt;For your own move, stop application writes for the final dump, or use a replication strategy. Restore into the local database, apply the app’s required migrations, and test through a temporary route or SSH forward. Keep credentials out of shell history and committed files.&lt;/p&gt;
&lt;p&gt;Check TLS settings too. My app previously forced database SSL for Railway; the local database did not provide that SSL listener. I changed that setting only for the container-local connection. Public traffic remains HTTPS through Cloudflare.&lt;/p&gt;
&lt;p&gt;Keep the old deployment until the cutover is verified. Once the local database accepts new writes, the old database becomes stale: rollback needs a plan for those writes, not just a DNS change.&lt;/p&gt;
&lt;h3 id=&quot;aws-replace-schedules-and-execution-assumptions&quot;&gt;AWS: replace schedules and execution assumptions&lt;/h3&gt;
&lt;p&gt;For a scheduled job, use a oneshot systemd service that runs the scraper container, plus a timer. An illustrative timer is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Unit]
Description=Daily scraper schedule

[Timer]
OnCalendar=*-*-* 02:00:00 Europe/Amsterdam
Persistent=true
Unit=my-scraper.service

[Install]
WantedBy=timers.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This needs a matching &lt;code&gt;my-scraper.service&lt;/code&gt; with the correct working directory and container command. &lt;code&gt;Persistent=true&lt;/code&gt; catches up a missed run when the timer becomes active again. Check the timezone and next run rather than assuming it matches the old cloud schedule.&lt;/p&gt;
&lt;p&gt;Verify one local run, its database writes and duplicate handling before enabling the timer and disabling the old AWS schedule. Do not leave two writers running unintentionally. My JobLab scraper uses local PostgreSQL; an earlier hosted-upload configuration was corrected and is not the current route.&lt;/p&gt;
&lt;p&gt;For Lambda, adapt the event handler into an HTTP application, or use an appropriate adapter. Replace IAM-role assumptions, keep external cloud APIs explicitly configured, and check timeouts, concurrency and access control. My personal-search deployment still had an unauthenticated search endpoint in its deployment record, so I would add authentication and rate limits before recommending that pattern for another public service.&lt;/p&gt;
&lt;h2 id=&quot;9-keep-it-recoverable&quot;&gt;9. Keep it recoverable&lt;/h2&gt;
&lt;p&gt;My regular checks are small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;free -h
df -h
sudo docker stats --no-stream
sudo docker compose ps
sudo docker compose logs --tail=50
systemctl list-timers --all
sudo journalctl -u cloudflared -n 30
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run the Compose commands inside the relevant application directory. Look at available memory, swap activity, disk space and the last successful scheduled job. A running container is not proof that its application is healthy.&lt;/p&gt;
&lt;p&gt;Use database-aware backups, keep a copy off this laptop and practise a restore into a separate database. Some of my stacks have nightly backups, but a backup on the same disk does not protect against losing that disk. Retain working application releases and record how to restore their matching database state.&lt;/p&gt;
&lt;p&gt;Test restart behaviour during planned maintenance, with backups and physical access available. Check Docker, the tunnel, timers and actual public endpoints afterward. Avoid rebooting a production host just to demonstrate a guide.&lt;/p&gt;
&lt;p&gt;Restrict SSH and dashboards. Docker’s published ports can bypass ordinary UFW rules, as its &lt;a href=&quot;https://docs.docker.com/engine/network/packet-filtering-firewalls/&quot;&gt;firewall documentation&lt;/a&gt; explains. My loopback-bound public application ports are reached through the tunnel, but administration access needs its own restrictions.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;Start with Ubuntu, a working SSH connection and the nginx example. Once you can start it, inspect it and bring it back after a planned restart, move one small application with a tested backup.&lt;/p&gt;
&lt;p&gt;Add the Battery page first because it only reads hardware state. Add Display and charging control after checking your laptop’s interfaces. The server does not need every feature on day one.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;My useful lessons were mundane: the short power-button press shut the server down, battery thresholds looked correct but were ignored, and the Display page stalled because process input was passed the wrong way. Another migration briefly used the wrong database destination.&lt;/p&gt;
&lt;p&gt;Those mistakes made me check effective settings, charging behaviour and actual writes. If you try this, keep a short record of what you changed and how you verified it. That record is often more useful than another dashboard.&lt;/p&gt;</content:encoded></item><item><title>Building JobLab on AWS Fargate</title><link>https://notes.oploy.eu/job-scraper-aws-fargate/</link><guid isPermaLink="true">https://notes.oploy.eu/job-scraper-aws-fargate/</guid><description>Explore the live JobLab dashboard, then inspect the Python, Docker, Terraform, ECS Fargate, S3, and Supabase pipeline that rebuilds its optimization-focused dataset each day.</description><pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in August 2026. Job totals and costs below are snapshots from then.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Start with the deployed result: &lt;strong&gt;&lt;a href=&quot;https://or.oploy.eu/&quot;&gt;explore the live JobLab dashboard&lt;/a&gt;&lt;/strong&gt;. Then return here for the code path that produces it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/joblab-01-overview.width-1400.png&quot; alt=&quot;JobLab dashboard: overview KPIs, jobs by search term and country&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;The dashboard is the observable output of a daily data build. Its charts query standardized records rather than raw scraped pages. A posting passes through source collection, cleaning, taxonomy mapping, skill and tool extraction, an S3 snapshot, a Supabase upsert, and database deduplication before it appears in the interface.&lt;/p&gt;
&lt;p&gt;The totals in these screenshots are snapshots because each scheduled run can add or remove records. Use the &lt;a href=&quot;https://or.oploy.eu/&quot;&gt;live dashboard&lt;/a&gt; for the current dataset, and use the repository to understand how each field was produced.&lt;/p&gt;
&lt;h2 id=&quot;my-instance-uses-optimization-specific-retrieval-rules&quot;&gt;My instance uses optimization-specific retrieval rules&lt;/h2&gt;
&lt;p&gt;The pipeline is reusable, but this deployed instance reflects my own search. In &lt;code&gt;1- Scrapped Data/job_scraper.py&lt;/code&gt;, &lt;code&gt;FILTER_KEYWORDS_TITLE&lt;/code&gt; casts a broad net with terms such as &lt;code&gt;optim&lt;/code&gt;, &lt;code&gt;operations research&lt;/code&gt;, &lt;code&gt;supply chain&lt;/code&gt;, &lt;code&gt;routing&lt;/code&gt;, &lt;code&gt;scheduling&lt;/code&gt;, &lt;code&gt;decision science&lt;/code&gt;, &lt;code&gt;data scientist&lt;/code&gt;, and &lt;code&gt;solver&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;FILTER_KEYWORDS_STRONG&lt;/code&gt; then checks descriptions for higher-precision terms such as &lt;code&gt;linear programming&lt;/code&gt;, &lt;code&gt;mixed integer&lt;/code&gt;, &lt;code&gt;MILP&lt;/code&gt;, &lt;code&gt;Gurobi&lt;/code&gt;, &lt;code&gt;CPLEX&lt;/code&gt;, &lt;code&gt;OR-Tools&lt;/code&gt;, &lt;code&gt;Pyomo&lt;/code&gt;, &lt;code&gt;vehicle routing&lt;/code&gt;, &lt;code&gt;stochastic optimization&lt;/code&gt;, and &lt;code&gt;demand planning&lt;/code&gt;. &lt;code&gt;FILTER_KEYWORDS_NEGATIVE&lt;/code&gt; rejects title matches for unrelated uses such as SEO, sales optimization, and marketing optimization.&lt;/p&gt;
&lt;p&gt;These are my domain choices, not built-in JobLab requirements. To build another instance, replace the title, strong, and negative keyword lists; update &lt;code&gt;src/config/skills_reference.json&lt;/code&gt; and &lt;code&gt;src/config/tools_reference.json&lt;/code&gt;; then review the taxonomy mappings in &lt;code&gt;3- Enrichment + Standardization/taxonomy_standardization.py&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;one-container-runs-the-complete-daily-build&quot;&gt;One container runs the complete daily build&lt;/h2&gt;
&lt;p&gt;EventBridge starts an ECS Fargate Spot task at 02:00 UTC. The container entrypoint is &lt;code&gt;src/orchestrate/run_weekly.py&lt;/code&gt;. It creates fresh stage databases, runs the scraper, preprocessing, and enrichment scripts as subprocesses, uploads an atomic snapshot to S3, upserts normalized rows through the Supabase REST API, removes duplicates, records stage counts, and exits.&lt;/p&gt;
&lt;p&gt;Runtime behavior comes from environment variables such as &lt;code&gt;SCRAPE_COUNTRIES&lt;/code&gt;, &lt;code&gt;SCRAPE_MAX_JOBS&lt;/code&gt;, &lt;code&gt;DAYS_BACK&lt;/code&gt;, &lt;code&gt;S3_BUCKET&lt;/code&gt;, &lt;code&gt;SUPABASE_URL&lt;/code&gt;, and &lt;code&gt;DRY_RUN&lt;/code&gt;. AWS credentials and service keys are supplied through SSM Parameter Store. Terraform provisions ECR, ECS, IAM, CloudWatch Logs, the schedule, and the task definition.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/job-scraper-aws-fargate-1.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/job-scraper-aws-fargate-1.svg&quot; alt=&quot;Diagram: EventBridge Scheduler triggers an ECS Fargate Spot task that runs the six-stage pipeline, which writes to S3 snapshots and Supabase Postgres, feeding a Next.js dashboard and a FastAPI agent&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;
&lt;em&gt;One scheduled container, three downstream consumers.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;inspect-each-deployable-component-on-github&quot;&gt;Inspect each deployable component on GitHub&lt;/h2&gt;
&lt;p&gt;JobLab is split by runtime responsibility. The repositories below contain the source and configuration discussed here.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Mbehbahani/joblab-data-pipeline&quot;&gt;Daily data pipeline&lt;/a&gt;: Inspect the six-stage Python pipeline, SQLite stage files, relevance filters, skill and tool vocabularies, Supabase exporter, Docker image, Terraform, and scheduled Fargate task. This is where you replace my optimization-specific retrieval rules.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Mbehbahani/joblab-analytics-frontend&quot;&gt;Analytics dashboard&lt;/a&gt;: Inspect the Next.js, TypeScript, Mantine, ECharts, Zustand, and Supabase frontend that turns standardized records into linked filters, charts, heatmaps, and job-level results. I wrote it up in &lt;a href=&quot;https://notes.oploy.eu/joblab-analytics-frontend/&quot;&gt;building the JobLab analytics frontend&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Mbehbahani/joblab-agent-api&quot;&gt;AI agent API&lt;/a&gt;: Inspect the FastAPI and Mangum API on AWS Lambda, the Bedrock tool-calling loop, structured and semantic retrieval, CV matching, MLflow traces, evaluation, and prompt optimization. The tracking server is described in &lt;a href=&quot;https://notes.oploy.eu/mlflow-railway/&quot;&gt;deploying MLflow on Railway&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Mbehbahani/joblab-lakehouse&quot;&gt;Lakehouse version&lt;/a&gt;: the same dataset as a PySpark Bronze, Silver and Gold pipeline, reproduced in Databricks Delta Lake.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;build-analytical-fields-from-noisy-text&quot;&gt;Build analytical fields from noisy text&lt;/h2&gt;
&lt;p&gt;Job titles alone are unreliable. A relevant role may be called &lt;code&gt;Decision Scientist&lt;/code&gt;, &lt;code&gt;Supply Chain Analyst&lt;/code&gt;, or &lt;code&gt;Applied Scientist&lt;/code&gt;. The enrichment stage assigns standardized job function and industry values, detects language and education signals, and extracts skills and optimization tools from the description.&lt;/p&gt;
&lt;p&gt;The controlled vocabularies live in &lt;code&gt;src/config/skills_reference.json&lt;/code&gt; and &lt;code&gt;src/config/tools_reference.json&lt;/code&gt;. Extraction code under &lt;code&gt;src/analysis/skill_extraction/&lt;/code&gt; keeps tools such as Gurobi, Pyomo, OR-Tools, and CPLEX separate from general skills. The dashboard can then group records consistently while preserving the original title and description for verification.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/joblab-02-structure.width-1400.png&quot; alt=&quot;JobLab dashboard: industry treemap, job functions, job type by level&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;validate-aggregates-against-source-records&quot;&gt;Validate aggregates against source records&lt;/h2&gt;
&lt;p&gt;The time series and country-by-function heatmap are useful because both respond to the same filter state. When a group changes unexpectedly, I can drill into its postings and check the original source, title, taxonomy, and extracted terms. This turns the frontend into a data-quality tool as well as a presentation layer.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://or.oploy.eu/&quot;&gt;Inspect the current trends in JobLab&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/joblab-03-temporal.width-1400.png&quot; alt=&quot;JobLab dashboard: remote status, country by function heatmap, jobs over time&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;match-the-compute-model-to-the-workload&quot;&gt;Match the compute model to the workload&lt;/h2&gt;
&lt;p&gt;The table is a snapshot of my AWS bill for &lt;strong&gt;June 2026&lt;/strong&gt;. It is not a fixed monthly price, and it will change with workload and AWS pricing.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Service&lt;/th&gt;&lt;th&gt;June 2026&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Elastic Container Service (Fargate Spot)&lt;/td&gt;&lt;td&gt;$0.36&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Virtual Private Cloud&lt;/td&gt;&lt;td&gt;$0.04&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Other&lt;/td&gt;&lt;td&gt;$0.15&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$0.55&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The batch runs for a few minutes and exits, so an always-on server would waste idle compute. Fargate runs the scheduled container, Lambda handles request-response AI traffic, and the frontend is deployed separately. Each component uses a runtime suited to its execution pattern.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/joblab-04-aws-cost.width-1400.png&quot; alt=&quot;AWS cost breakdown for June 2026: ECS, VPC and other services&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;infrastructure-work-is-larger-than-the-compute-bill&quot;&gt;Infrastructure work is larger than the compute bill&lt;/h2&gt;
&lt;p&gt;The first deployment required an ECR repository, image build and push, ECS cluster and task definition, execution and task IAM roles, CloudWatch log group, SSM parameters, networking, and an EventBridge schedule. Terraform makes those resources reproducible, but it does not remove the need to understand their permissions and failure modes.&lt;/p&gt;
&lt;p&gt;Secrets belong in SSM Parameter Store and deployment environments. After &lt;code&gt;terraform apply&lt;/code&gt; and the image deployment, &lt;code&gt;scripts/run-manual.sh&lt;/code&gt; can trigger a test task and &lt;code&gt;aws logs tail /ecs/joblab-pipeline --follow&lt;/code&gt; can verify every stage before the daily schedule is trusted.&lt;/p&gt;
&lt;h2 id=&quot;reuse-the-right-level&quot;&gt;Reuse the right level&lt;/h2&gt;
&lt;p&gt;Use &lt;a href=&quot;https://or.oploy.eu/&quot;&gt;JobLab Analytics&lt;/a&gt; to inspect my deployed optimization-focused instance. Use &lt;a href=&quot;https://jobpilot.oploy.eu/en/app/my-job-search&quot;&gt;JobPilot My Job Search&lt;/a&gt; (sign-in required) when you want a personal search with your own keywords and no infrastructure deployment. Fork the data-pipeline repository when you need control over sources, countries, relevance rules, taxonomies, storage, and scheduling.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Fork &lt;a href=&quot;https://github.com/Mbehbahani/joblab-data-pipeline&quot;&gt;joblab-data-pipeline&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Replace the three keyword lists in &lt;code&gt;1- Scrapped Data/job_scraper.py&lt;/code&gt; and the vocabularies in &lt;code&gt;src/config/skills_reference.json&lt;/code&gt; and &lt;code&gt;src/config/tools_reference.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Put AWS credentials and service keys in SSM Parameter Store, never in the repository.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;terraform apply&lt;/code&gt;, then build and push the image to ECR.&lt;/li&gt;
&lt;li&gt;Trigger one task by hand with &lt;code&gt;scripts/run-manual.sh&lt;/code&gt; and follow its logs:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;aws logs tail /ecs/joblab-pipeline --follow
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;6&quot;&gt;
&lt;li&gt;Only trust the 02:00 UTC schedule after one manual run has passed every stage.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One thing to check first: &lt;code&gt;terraform/variables.tf&lt;/code&gt; names the log group &lt;code&gt;/ecs/joblab-pipeline&lt;/code&gt;, while &lt;code&gt;scripts/run-manual.sh&lt;/code&gt; prints &lt;code&gt;/ecs/joblab&lt;/code&gt;. Use the name Terraform created.&lt;/p&gt;
&lt;p&gt;For a smaller service on a simpler platform, see &lt;a href=&quot;https://notes.oploy.eu/deploying-fastapi-on-railway/&quot;&gt;how I deployed FastAPI on Railway&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Building JobPilot&apos;s Technical Architecture</title><link>https://notes.oploy.eu/jobpilot-architecture/</link><guid isPermaLink="true">https://notes.oploy.eu/jobpilot-architecture/</guid><description>Inside JobPilot&apos;s SvelteKit, Convex, FastAPI, Supabase, Gmail, and task-agent architecture, from a personal search request to an updated application board.</description><pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in April 2026.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;JobPilot is a multi-service application rather than a single prompt wrapped in a user interface. I built the main application with SvelteKit, store reactive task state in Convex, run personal job retrieval through a separate FastAPI service, persist search data in Supabase Postgres, and use Gmail signals to update the application board.&lt;/p&gt;
&lt;p&gt;The technical design follows one rule: search results, imported job descriptions, generated documents, and email outcomes must all resolve to the same structured task record.&lt;/p&gt;
&lt;h2 id=&quot;system-boundaries-and-data-flow&quot;&gt;System boundaries and data flow&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-89af62dbe5f2.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-89af62dbe5f2.svg&quot; alt=&quot;SvelteKit UI calls Convex and the personal search API routes; the FastAPI search backend writes to Supabase Postgres; Convex runs the task agent, receives Gmail signals and holds the Kanban task state&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;SvelteKit owns pages and server routes. Convex owns user-scoped application state and orchestration. The Python service owns scraping, normalization, deduplication, and ranking. Supabase stores search profiles, runs, canonical jobs, and matches. This separation keeps a slow search run or external API failure away from the interactive task-board runtime.&lt;/p&gt;
&lt;h2 id=&quot;1-personal-search-as-a-separate-service&quot;&gt;1. Personal search as a separate service&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;My Job Search&lt;/strong&gt; page sends user-owned keywords, location, country, date window, source selection, and remote preference through &lt;code&gt;src/routes/api/personal-search/+server.ts&lt;/code&gt;. Keyword suggestions are handled separately in &lt;code&gt;suggest-keywords/+server.ts&lt;/code&gt;, and &lt;code&gt;send-to-tasks/+server.ts&lt;/code&gt; converts selected results into application tasks.&lt;/p&gt;
&lt;p&gt;The backend performs scraping, normalization, deduplication, ranking, and selective enrichment before writing structured results to Supabase. Unlike &lt;a href=&quot;https://notes.oploy.eu/job-scraper-aws-fargate/&quot;&gt;JobLab&lt;/a&gt;, this instance is not locked to my optimization search. Each user supplies their own terms in &lt;a href=&quot;https://jobpilot.oploy.eu/en/app/my-job-search&quot;&gt;My Job Search&lt;/a&gt;, or derives them from a CV profile, without redeploying the shared service.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://youtu.be/hFBIUkhTLcc&quot;&gt;Watch the video walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;2-normalize-every-job-into-one-task-schema&quot;&gt;2. Normalize every job into one task schema&lt;/h2&gt;
&lt;p&gt;A job can enter from pasted text, a URL, a company page, or a personal search result. &lt;code&gt;src/lib/convex/todo/jobInput.ts&lt;/code&gt; normalizes those inputs before the task agent works on them, and the task fields are defined in &lt;code&gt;src/lib/convex/schema.ts&lt;/code&gt;. Role, company, source URL, description, status, and generated artifacts therefore stay attached to one record.&lt;/p&gt;
&lt;p&gt;Whatever the source, the result is the same set of fields on one task:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/1-7.width-1400.png&quot; alt=&quot;JobPilot task form after reading a LinkedIn posting: job title, company, position, job URL, platform, country and level filled in&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://youtu.be/N92yHginPRw&quot;&gt;Watch the video walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;3-bound-the-task-agent-with-stored-context&quot;&gt;3. Bound the task agent with stored context&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;src/lib/convex/todo/agent.ts&lt;/code&gt; defines when the agent may generate a motivation letter. It reads the job description, the stored CV or profile, and the user’s reusable letter instructions. It generates only when the task reaches the preparing stage and the letter field is empty, which prevents an agent run from silently overwriting a reviewed draft.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;src/lib/convex/userSettings.ts&lt;/code&gt; manages the reusable profile and instructions. &lt;code&gt;src/lib/convex/todo/messages.ts&lt;/code&gt; runs the model interaction, and the resulting letter is written back to the existing task for review, editing, copying, or download. The model call is one bounded step inside application state, not the state itself.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://youtu.be/PKUBM6oVpmk&quot;&gt;Watch the video walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;4-match-gmail-events-to-existing-tasks&quot;&gt;4. Match Gmail events to existing tasks&lt;/h2&gt;
&lt;p&gt;The Gmail integration is implemented in &lt;code&gt;src/lib/convex/gmail.ts&lt;/code&gt;. OAuth requests read-only Gmail access. The sync retrieves recent messages, extracts sender, company, position, interview links, and date text, then scores candidate tasks with &lt;code&gt;scoreTaskMatch&lt;/code&gt;. Strong company and position evidence increases confidence before an application record is updated.&lt;/p&gt;
&lt;p&gt;Heuristics handle clear interview, rejection, acceptance, and follow-up signals first. A model fallback is reserved for messages that remain ambiguous. This ordering controls latency and model use while keeping the final task mutation explicit. Gmail is an input signal; Convex remains the source of application state.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://youtu.be/FQtYAEoMMyM&quot;&gt;Watch the video walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;engineering-decisions-behind-the-workflow&quot;&gt;Engineering decisions behind the workflow&lt;/h2&gt;
&lt;p&gt;The project keeps interactive state, long-running search, persistence, external email, and model calls behind separate interfaces. It also includes retry-aware actions, stuck-task cleanup, duplicate prevention, and explicit user-visible states. Those details matter more than the number of features because they determine whether the workflow survives partial failure.&lt;/p&gt;
&lt;p&gt;To adapt JobPilot, start in &lt;a href=&quot;https://jobpilot.oploy.eu/en/app/my-job-search&quot;&gt;My Job Search&lt;/a&gt; with your own keywords. For code-level changes, the main extension points are the SvelteKit API routes, Convex schema and actions, the FastAPI search service, and the Supabase search schema.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;The code is in &lt;a href=&quot;https://github.com/Mbehbahani/JobPilot&quot;&gt;Mbehbahani/JobPilot&lt;/a&gt;. You need Bun, Node.js and a Convex project; put &lt;code&gt;CONVEX_DEPLOYMENT&lt;/code&gt; and &lt;code&gt;PUBLIC_CONVEX_URL&lt;/code&gt; in &lt;code&gt;.env.local&lt;/code&gt;. Then one command runs the SvelteKit app and the Convex backend side by side:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/Mbehbahani/JobPilot
cd JobPilot
bun install
bun run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Read &lt;code&gt;src/lib/convex/schema.ts&lt;/code&gt; first: every other part writes into the task record defined there.&lt;/li&gt;
&lt;li&gt;Add one job from a URL and check which fields &lt;code&gt;src/lib/convex/todo/jobInput.ts&lt;/code&gt; filled in.&lt;/li&gt;
&lt;li&gt;Move the task to the preparing stage and confirm the agent writes a letter only when the letter field is empty (&lt;code&gt;src/lib/convex/todo/agent.ts&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Gmail and the personal search service need their own credentials, so start without them. To use the hosted version instead, open &lt;a href=&quot;https://jobpilot.oploy.eu/&quot;&gt;JobPilot&lt;/a&gt; (sign-in required). The per-user model connection is described in &lt;a href=&quot;https://www.oploy.eu/blog/connecting-chatgpt-to-apps/&quot;&gt;Connecting JobPilot to ChatGPT&lt;/a&gt; on the old blog.&lt;/p&gt;</content:encoded></item><item><title>Deploying MLflow on Railway</title><link>https://notes.oploy.eu/mlflow-railway/</link><guid isPermaLink="true">https://notes.oploy.eu/mlflow-railway/</guid><description>How I deployed a shared MLflow tracking server with PostgreSQL metadata and external artifacts, then connected JobLab tracing, evaluation, feedback, and prompt optimization to it.</description><pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in March 2026. The monthly cost is a snapshot from then. Corrected in October 2026: the artifact storage variable was wrong (see What went wrong).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I deployed MLflow because local &lt;code&gt;mlflow ui&lt;/code&gt; processes and SQLite files could not provide a stable tracking target for several repositories. The Railway deployment (&lt;a href=&quot;https://github.com/Mbehbahani/mlflow-tracking-server&quot;&gt;mlflow-tracking-server&lt;/a&gt;) runs the tracking server as a container, stores MLflow metadata in PostgreSQL, and sends artifacts to external object storage.&lt;/p&gt;
&lt;p&gt;My &lt;a href=&quot;https://notes.oploy.eu/job-scraper-aws-fargate/&quot;&gt;JobLab&lt;/a&gt; agent (&lt;a href=&quot;https://github.com/Mbehbahani/joblab-agent-api&quot;&gt;joblab-agent-api&lt;/a&gt;) uses this server for production request traces, tool spans, evaluation runs, feedback assessments, and prompt-optimization results. The observed deployment was about &lt;strong&gt;$20 to $25 per month&lt;/strong&gt;. That is a snapshot from my services, not a fixed platform price.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://youtu.be/velO_OEStKc&quot;&gt;Watch the video walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;deploy-and-verify-the-tracking-server&quot;&gt;Deploy and verify the tracking server&lt;/h2&gt;
&lt;h3 id=&quot;1-create-the-services&quot;&gt;1. Create the services&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/1-_template_selection.width-950.png&quot; alt=&quot;Railway new-project menu with Template selected&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;The template creates the MLflow service and its PostgreSQL metadata dependency.&lt;/p&gt;
&lt;h3 id=&quot;2-inspect-the-mlflow-container&quot;&gt;2. Inspect the MLflow container&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/2-_mlflow_selection.width-950.png&quot; alt=&quot;Railway template search showing the MLflow full template&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;Confirm the tracking process, database variables, public domain, and restart behavior before connecting an application.&lt;/p&gt;
&lt;h3 id=&quot;3-configure-clients&quot;&gt;3. Configure clients&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;MLFLOW_TRACKING_URI=https://your-mlflow-url
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The JobLab backend reads this value through its settings layer. Evaluation scripts also call &lt;code&gt;mlflow.set_tracking_uri()&lt;/code&gt; explicitly so local runs and production requests resolve to the same server.&lt;/p&gt;
&lt;h3 id=&quot;4-verify-experiment-metadata&quot;&gt;4. Verify experiment metadata&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/4-_overview_dashboard.width-950.png&quot; alt=&quot;MLflow experiment overview with request counts, latency and token usage&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;Create a test experiment and run, then confirm parameters, metrics, tags, and artifacts survive a service restart.&lt;/p&gt;
&lt;h3 id=&quot;5-verify-traces&quot;&gt;5. Verify traces&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/5-_traces.width-950.png&quot; alt=&quot;MLflow traces list with requests, responses, latency and assessments&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;Send one agent request and confirm it creates one root trace with child LLM and tool spans.&lt;/p&gt;
&lt;h3 id=&quot;6-inspect-span-timing&quot;&gt;6. Inspect span timing&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/6-_view_spans.width-950.png&quot; alt=&quot;One MLflow trace opened, with its spans, inputs, outputs and feedback&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;Check tool latency, model latency, retries, and the inputs that produced the response.&lt;/p&gt;
&lt;h2 id=&quot;separate-metadata-from-artifacts&quot;&gt;Separate metadata from artifacts&lt;/h2&gt;
&lt;p&gt;The tracking server is stateless enough to restart because PostgreSQL keeps experiment and run metadata. Large files, datasets, and model artifacts belong in object storage. The diagram below shows those responsibilities.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mlflow-railway-1.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mlflow-railway-1.svg&quot; alt=&quot;Diagram of one MLflow server on Railway using PostgreSQL for metadata and external storage for artifacts&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;
&lt;em&gt;One server, two external stores: artifacts never live on Railway.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do not keep durable artifacts only on the Railway container filesystem.&lt;/strong&gt; The Railway template configures external storage with &lt;code&gt;BACKEND_S3=s3://your-bucket&lt;/code&gt; plus scoped &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;, &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; and &lt;code&gt;AWS_DEFAULT_REGION&lt;/code&gt;. Then test artifact upload and download after restarting the MLflow service.&lt;/p&gt;
&lt;h2 id=&quot;how-joblab-uses-the-shared-server&quot;&gt;How JobLab uses the shared server&lt;/h2&gt;
&lt;p&gt;The agent backend creates request-wide traces in &lt;code&gt;app/routers/ai.py&lt;/code&gt;. Tool spans are added in &lt;code&gt;app/services/joblab_tools.py&lt;/code&gt;, while &lt;code&gt;app/services/mlflow_lite.py&lt;/code&gt; provides a REST fallback and S3-backed spool when the full SDK or tracking server is unavailable.&lt;/p&gt;
&lt;p&gt;The evaluation pipeline is kept under &lt;code&gt;MLFLOW_STEPS/&lt;/code&gt;: &lt;code&gt;step1_create_experiment.py&lt;/code&gt; creates the experiment, &lt;code&gt;step2_register_assets.py&lt;/code&gt; registers versioned assets, &lt;code&gt;step3_run_evaluation.py&lt;/code&gt; runs the baseline and links backend traces, and &lt;code&gt;step4_optimization.py&lt;/code&gt; evaluates prompt candidates. Custom scoring logic lives in &lt;code&gt;evals/mlflow_scorers.py&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The target trace shape is one user question, one root agent trace, and nested model and tool spans. Distributed trace context connects evaluation calls to backend execution so the UI can show the whole path instead of unrelated fragments.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mlflow-railway-2.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mlflow-railway-2.svg&quot; alt=&quot;Diagram of several repositories logging to the same MLflow tracking server&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;
&lt;em&gt;Every repo logs to the same tracking server.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-made-this-production-useful&quot;&gt;What made this production-useful&lt;/h2&gt;
&lt;p&gt;Hosting the UI was the easy part. The useful work was persistent metadata, durable artifacts, one-question-one-trace structure, prompt provenance, feedback assessments, distributed context, and a fallback path for tracking outages. Those pieces turned MLflow from a local run viewer into the observability and evaluation layer for the JobLab agent.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Deploy the &lt;a href=&quot;https://railway.com/deploy/mlflow-full&quot;&gt;MLflow template on Railway&lt;/a&gt;. It creates the MLflow service and its PostgreSQL database. In October 2026 it shipped MLflow 3.10.1.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;BACKEND_S3&lt;/code&gt; and the three AWS variables so artifacts go to your own bucket.&lt;/li&gt;
&lt;li&gt;Point every client at the server:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export MLFLOW_TRACKING_URI=https://your-mlflow-url
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;Log one test run with a small artifact, restart the MLflow service, and check that both are still there.&lt;/li&gt;
&lt;li&gt;For tracing and evaluation, compare with &lt;code&gt;app/routers/ai.py&lt;/code&gt; and &lt;code&gt;MLFLOW_STEPS/&lt;/code&gt; in &lt;a href=&quot;https://github.com/Mbehbahani/joblab-agent-api&quot;&gt;joblab-agent-api&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &lt;a href=&quot;https://mlflow.org/docs/latest/&quot;&gt;MLflow documentation&lt;/a&gt; covers the server options. For a smaller service on the same platform, see &lt;a href=&quot;https://notes.oploy.eu/deploying-fastapi-on-railway/&quot;&gt;how I deployed FastAPI on Railway&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;The first version of this note told you to set &lt;code&gt;MLFLOW_ARTIFACT_ROOT&lt;/code&gt;. MLflow does not read a variable with that name, and neither does the Railway template. If you followed it, your artifacts may still be on the container filesystem and will disappear on the next redeploy. Use &lt;code&gt;BACKEND_S3&lt;/code&gt; as above, then repeat the restart test in step 4.&lt;/p&gt;</content:encoded></item><item><title>Deploying FastAPI on Railway</title><link>https://notes.oploy.eu/deploying-fastapi-on-railway/</link><guid isPermaLink="true">https://notes.oploy.eu/deploying-fastapi-on-railway/</guid><description>How I containerized a FastAPI optimization API, deployed it from GitHub to Railway, exposed /solve, and connected a separate frontend to the public endpoint.</description><pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in February 2026. The monthly cost is a snapshot from then.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This deployment serves an optimization backend, the scheduler from my &lt;a href=&quot;https://notes.oploy.eu/repair-shop-schedule/&quot;&gt;repair-shop scheduling work&lt;/a&gt;, from the public &lt;code&gt;POST /solve&lt;/code&gt; endpoint. The API code lives in &lt;code&gt;app/&lt;/code&gt;, the repository includes a &lt;code&gt;Dockerfile&lt;/code&gt;, and Railway builds that container after each GitHub deployment. A separate frontend sends the model input to the public API URL and renders the solution.&lt;/p&gt;
&lt;p&gt;I had already deployed FastAPI on ECS and on Lambda (&lt;a href=&quot;https://github.com/Mbehbahani/joblab-agent-api&quot;&gt;joblab-agent-api&lt;/a&gt;). For this small, continuously available API, Railway reduced the amount of infrastructure I needed to operate while keeping the container boundary explicit.&lt;/p&gt;
&lt;h2 id=&quot;from-local-process-to-public-container&quot;&gt;From local process to public container&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-866bf4ec2d70.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-866bf4ec2d70.svg&quot; alt=&quot;Local FastAPI app, git push to GitHub, Railway builds the Dockerfile, public Railway domain; the frontend calls that domain&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The deployment path is short, but every boundary is testable:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start &lt;code&gt;app.main:app&lt;/code&gt; locally with &lt;code&gt;uvicorn app.main:app --reload&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exercise &lt;code&gt;GET /&lt;/code&gt;, &lt;code&gt;GET /docs&lt;/code&gt;, and &lt;code&gt;POST /solve&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Push the repository to GitHub.&lt;/li&gt;
&lt;li&gt;Let Railway detect the root &lt;code&gt;Dockerfile&lt;/code&gt; and build the image.&lt;/li&gt;
&lt;li&gt;Generate a public domain and repeat the endpoint tests against it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The OpenAPI page at &lt;code&gt;/docs&lt;/code&gt; is especially useful here because it verifies request validation and the deployed route before the frontend is connected.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://youtu.be/oHoWq4DI1n4&quot;&gt;Watch the video walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;runtime-and-cost-are-deployment-settings&quot;&gt;Runtime and cost are deployment settings&lt;/h2&gt;
&lt;p&gt;Railway can stop an idle service when serverless mode is enabled. That reduces compute usage for a demonstration API, but the first request after sleep can take longer. An always-active service removes that cold-start tradeoff and consumes resources continuously.&lt;/p&gt;
&lt;p&gt;My small instance was around &lt;strong&gt;$1 per month&lt;/strong&gt; at the time of measurement. That number is not part of the architecture and is not a forecast. Request volume, memory, CPU time, egress, plan rules, and current Railway pricing determine the real bill.&lt;/p&gt;
&lt;h2 id=&quot;what-i-monitor-after-deployment&quot;&gt;What I monitor after deployment&lt;/h2&gt;
&lt;p&gt;A successful build is only the first check. I use Railway’s service view to inspect application logs, CPU, memory, restarts, and resource estimates. I then call &lt;code&gt;/docs&lt;/code&gt; and &lt;code&gt;/solve&lt;/code&gt; from outside Railway so the test covers the public domain, TLS termination, container process, FastAPI validation, and solver response.&lt;/p&gt;
&lt;h2 id=&quot;connect-the-frontend-through-configuration&quot;&gt;Connect the frontend through configuration&lt;/h2&gt;
&lt;p&gt;The frontend should not contain a hard-coded production hostname. It reads the solver endpoint from an environment variable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;NEXT_PUBLIC_API_URL=https://your-railway-domain/solve
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For local development, the same client can target:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;http://127.0.0.1:8000/solve
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps frontend builds environment-specific without changing application code. If you adapt the project, update the request schema on both sides and configure FastAPI CORS for the exact frontend origins you deploy.&lt;/p&gt;
&lt;h2 id=&quot;what-this-project-demonstrates&quot;&gt;What this project demonstrates&lt;/h2&gt;
&lt;p&gt;The useful part of this example is the complete connection between a model, an HTTP contract, a reproducible container, a public runtime, and a separately deployed frontend. Railway removes several infrastructure steps, but the deployment still depends on correct process binding, environment configuration, CORS, endpoint validation, logs, and cost monitoring.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;The backend is &lt;a href=&quot;https://github.com/oploy-eu/BigPictureFastAPI&quot;&gt;BigPictureFastAPI&lt;/a&gt; and the client is &lt;a href=&quot;https://github.com/oploy-eu/BigPictureFrontend&quot;&gt;BigPictureFrontend&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/oploy-eu/BigPictureFastAPI
cd BigPictureFastAPI
pip install -r requirements.txt
uvicorn app.main:app --reload
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;http://127.0.0.1:8000/docs&lt;/code&gt; and send one request to &lt;code&gt;POST /solve&lt;/code&gt; from there.&lt;/li&gt;
&lt;li&gt;Push the repository to your own GitHub account and create a Railway service from it. Railway finds the root &lt;code&gt;Dockerfile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Generate a public domain and repeat step 1 against it.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt; in the frontend to that domain plus &lt;code&gt;/solve&lt;/code&gt;, and add the frontend origin to the FastAPI CORS settings.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &lt;a href=&quot;https://opt.oploy.eu/&quot;&gt;live optimization demo&lt;/a&gt; shows the finished result. For the database side of the platform, see &lt;a href=&quot;https://notes.oploy.eu/postgresql-railway/&quot;&gt;PostgreSQL on Railway&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;I wrote above that the frontend should not contain a hard-coded hostname. My own frontend breaks that rule. In &lt;code&gt;src/lib/usePostSolve.ts&lt;/code&gt;, the client falls back to the old Railway URL when &lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt; is not set, and that Railway service no longer exists (it returns 404). The live demo still works because its build sets the variable and now calls a self-hosted endpoint. If you fork the frontend, remove the fallback so a missing variable fails loudly.&lt;/p&gt;</content:encoded></item><item><title>Testing an SCND Cost Function Before Optimization</title><link>https://notes.oploy.eu/optimization-starts-with-the-cost-function/</link><guid isPermaLink="true">https://notes.oploy.eu/optimization-starts-with-the-cost-function/</guid><description>How I used a five-year supply-chain network demo to test CAPEX, fixed OPEX, transport cost, opening time, and capacity tradeoffs before building the optimizer.</description><pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Before I wrote the full supply-chain network optimizer, I implemented the objective as a standalone scenario evaluator. That let me test units, timing, capacity bands, allocation logic, and cost dominance without involving solver behavior.&lt;/p&gt;
&lt;p&gt;A solver can minimize a function exactly as written and still produce the wrong business decision. The evaluator made the economic assumptions visible first.&lt;/p&gt;
&lt;h2 id=&quot;model-the-objective-as-an-independent-component&quot;&gt;Model the objective as an independent component&lt;/h2&gt;
&lt;p&gt;I built &lt;a href=&quot;https://scnd.oploy.eu&quot;&gt;scnd.oploy.eu&lt;/a&gt; as an interactive evaluator for a Netherlands network over 2026 to 2030. A scenario chooses fulfillment-center opening periods and capacity levels. The evaluation layer allocates demand to the nearest open facility, checks capacity and shortfall, and aggregates the cost by half-year period.&lt;/p&gt;
&lt;p&gt;The implemented objective has four parts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;total_cost = opening_capex
           + fixed_facility_opex
           + hub_to_facility_transport
           + facility_to_demand_transport
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Opening CAPEX is charged once in the selected period. Fixed OPEX repeats while a facility is active and depends on its capacity band. Transport OPEX depends on both network geometry and allocated flow. The user interface exposes these terms separately so one large component cannot hide a modeling error in another.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-f4bbe52eb68e.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-f4bbe52eb68e.svg&quot; alt=&quot;Four cost terms (one-time opening CAPEX, recurring facility OPEX, hub to facility transport, facility to demand transport) add up to the total objective&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/Total__107.9.width-1400.png&quot; alt=&quot;Scenario A in the SCND demo, with a total cost of 107.9 M EUR&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;Scenario A: &lt;strong&gt;107.9 M€&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/Total__105.2.width-1400.png&quot; alt=&quot;Scenario B in the SCND demo, with a total cost of 105.2 M EUR&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;Scenario B: &lt;strong&gt;105.2 M€&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Both networks satisfy the same feasibility checks, but Scenario B evaluates &lt;strong&gt;2.7 million euros lower&lt;/strong&gt;. The opening schedule and selected capacity bands change when CAPEX begins, how many periods incur fixed OPEX, and how far demand travels.&lt;/p&gt;
&lt;p&gt;This comparison is a unit test for the decision logic. I expect an earlier opening to increase facility cost and possibly reduce transport cost. If the component table does not move in that direction, I inspect period indexing, distance units, cost multipliers, and facility activation before trusting an optimizer.&lt;/p&gt;
&lt;h2 id=&quot;checks-i-run-before-solver-integration&quot;&gt;Checks I run before solver integration&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A facility pays opening CAPEX exactly once.&lt;/li&gt;
&lt;li&gt;Fixed OPEX starts in the opening period and continues only while active.&lt;/li&gt;
&lt;li&gt;Capacity is compared with flow in the same unit and time period.&lt;/li&gt;
&lt;li&gt;Transport costs use the intended distance and load units.&lt;/li&gt;
&lt;li&gt;Infeasible shortfall is reported separately from economic cost.&lt;/li&gt;
&lt;li&gt;Identical scenarios return identical component totals.&lt;/li&gt;
&lt;li&gt;Small changes in timing produce explainable changes in the breakdown.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once these checks pass, the same evaluator can become the objective for a MILP, genetic algorithm, particle-swarm method, or another search procedure. The optimization method can change without changing the business accounting.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Open the &lt;a href=&quot;https://scnd.oploy.eu&quot;&gt;SCND demo&lt;/a&gt;. It runs in the browser; nothing to install.&lt;/li&gt;
&lt;li&gt;Build one scenario and write down the four component totals, not only the final number.&lt;/li&gt;
&lt;li&gt;Open one facility a period earlier. Fixed OPEX should go up. Transport cost should stay the same or go down.&lt;/li&gt;
&lt;li&gt;If a component moves the other way, check period indexing, distance units, cost multipliers and facility activation before you trust any optimizer built on top.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The same habit later caught a cost function that rewards stockouts in my &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation/&quot;&gt;inventory simulation&lt;/a&gt;, and the optimizer it misled is in &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation-optimization/&quot;&gt;optimizing reorder points with a noisy simulator&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Building the JobLab Analytics Frontend</title><link>https://notes.oploy.eu/joblab-analytics-frontend/</link><guid isPermaLink="true">https://notes.oploy.eu/joblab-analytics-frontend/</guid><description>How I built the Next.js, TypeScript, Mantine, ECharts, Zustand, and Supabase frontend that turns JobLab&apos;s normalized job records into interactive analysis.</description><pubDate>Mon, 01 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in December 2025. The dashboard has since moved to &lt;a href=&quot;https://or.oploy.eu/&quot;&gt;or.oploy.eu&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;JobLab is the frontend layer of a larger data system. A scheduled Python pipeline (&lt;a href=&quot;https://github.com/Mbehbahani/joblab-data-pipeline&quot;&gt;joblab-data-pipeline&lt;/a&gt;, described in &lt;a href=&quot;https://notes.oploy.eu/job-scraper-aws-fargate/&quot;&gt;building JobLab on AWS Fargate&lt;/a&gt;) writes normalized postings to Supabase Postgres. This Next.js application reads those records, keeps filter state in Zustand, and renders linked analytical views with Apache ECharts and Mantine components.&lt;/p&gt;
&lt;p&gt;The deployed instance at &lt;a href=&quot;https://or.oploy.eu/&quot;&gt;or.oploy.eu&lt;/a&gt; is tuned to optimization, operations research, supply chain, decision science, and related quantitative roles. The frontend architecture is reusable with another job taxonomy and dataset.&lt;/p&gt;
&lt;h2 id=&quot;frontend-architecture&quot;&gt;Frontend architecture&lt;/h2&gt;
&lt;p&gt;The application uses the Next.js App Router. Dashboard pages live under &lt;code&gt;src/app/dashboard/&lt;/code&gt;, reusable chart components under &lt;code&gt;src/components/charts/&lt;/code&gt;, filter controls under &lt;code&gt;src/components/filters/&lt;/code&gt;, and shared state under &lt;code&gt;src/store/&lt;/code&gt;. Server routes in &lt;code&gt;src/app/api/&lt;/code&gt; separate trusted backend calls from browser-side Supabase queries.&lt;/p&gt;
&lt;p&gt;The main data path is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Supabase job records -&gt; API and query layer -&gt; Zustand filter state -&gt; ECharts options -&gt; linked dashboard views
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A country, function, skill, or time selection therefore changes a shared query context rather than creating unrelated charts.&lt;/p&gt;
&lt;h2 id=&quot;linked-views-over-one-normalized-schema&quot;&gt;Linked views over one normalized schema&lt;/h2&gt;
&lt;p&gt;The interface includes a job table, education distributions, industry analysis, skills and tools, search terms, country, experience level, job function, and time-series views. Heatmaps, treemaps, maps, and timelines all depend on standardized fields produced upstream.&lt;/p&gt;
&lt;p&gt;This is why the pipeline and frontend are separate repositories. Scraping and taxonomy changes belong in the data pipeline. Visual aggregation, filtering, interaction, and responsive layout belong here.&lt;/p&gt;
&lt;h2 id=&quot;configuration-and-extension-points&quot;&gt;Configuration and extension points&lt;/h2&gt;
&lt;p&gt;The browser needs &lt;code&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/code&gt; for approved client-side access. Trusted server routes can use &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt;, and &lt;code&gt;LLM_BACKEND_URL&lt;/code&gt; connects the optional AI and CV services. &lt;code&gt;NEXT_PUBLIC_SITE_URL&lt;/code&gt; supplies the canonical deployment URL.&lt;/p&gt;
&lt;p&gt;To reuse the frontend for another market, change the upstream search and taxonomy first, then update the TypeScript types, available filters, labels, and chart groupings. Replacing only the page title would leave the optimization-specific schema assumptions in place.&lt;/p&gt;
&lt;h2 id=&quot;why-the-dashboard-links-back-to-records&quot;&gt;Why the dashboard links back to records&lt;/h2&gt;
&lt;p&gt;Aggregates can hide duplicate posts, title variation, and small sample sizes. Each filtered view therefore remains connected to the underlying job results. I can use a chart to find a pattern, then open the postings that produced it and inspect whether the classification is credible.&lt;/p&gt;
&lt;p&gt;That drill-down is also a practical quality check for &lt;code&gt;job_function_std&lt;/code&gt;, industry, skills, tools, remote status, and location fields.&lt;/p&gt;
&lt;h2 id=&quot;what-this-frontend-demonstrates&quot;&gt;What this frontend demonstrates&lt;/h2&gt;
&lt;p&gt;The technical work is the connection between a normalized analytical schema and an interface that stays understandable as filters interact. Next.js provides routing and server boundaries, Supabase provides the data layer, Zustand coordinates client state, and ECharts handles the dense visual relationships. The public site is one deployed instance of that architecture.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/Mbehbahani/joblab-analytics-frontend
cd joblab-analytics-frontend
npm install
cp .env.example .env.local
npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;In &lt;code&gt;.env.local&lt;/code&gt;, set &lt;code&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/code&gt; to a Supabase project that holds job records in the pipeline’s schema. Keep &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; for server routes only.&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;http://localhost:3000/dashboard&lt;/code&gt;, pick one country filter, and check that every chart changes together.&lt;/li&gt;
&lt;li&gt;Click into one bar and compare the postings with the aggregate before you trust the chart.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The records come from the &lt;a href=&quot;https://notes.oploy.eu/job-scraper-aws-fargate/&quot;&gt;JobLab data pipeline&lt;/a&gt;. The finished dashboard is at &lt;a href=&quot;https://or.oploy.eu/&quot;&gt;or.oploy.eu&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>PostgreSQL on Railway: Simple Setup, Honest Costs</title><link>https://notes.oploy.eu/postgresql-railway/</link><guid isPermaLink="true">https://notes.oploy.eu/postgresql-railway/</guid><description>How I connected the oploy.eu Django and Wagtail site to Railway PostgreSQL, configured SSL and persistent connections, checked latency, and monitored the real deployment.</description><pubDate>Sat, 01 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in November 2025. Since September 2026 oploy.eu no longer runs on Railway; it moved to a server I run myself. The Railway setup below is how it worked until then, and the costs are snapshots from that time.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The oploy.eu website runs on Django and Wagtail. When I wrote this, its PostgreSQL database was hosted on Railway. The important part is the application connection: Railway supplies one &lt;code&gt;DATABASE_URL&lt;/code&gt;, Django parses it in &lt;code&gt;wagtailio/settings/base.py&lt;/code&gt;, SSL is required, and persistent connections are kept for 600 seconds.&lt;/p&gt;
&lt;p&gt;The screenshots show the service I actually deployed. They are evidence from one small workload, not a generic benchmark or a fixed price quote.&lt;/p&gt;
&lt;h2 id=&quot;why-this-deployment-uses-railway&quot;&gt;Why this deployment uses Railway&lt;/h2&gt;
&lt;p&gt;I compared Google Cloud SQL, AWS RDS, Heroku Postgres, Fly.io Postgres, and Railway. For this Wagtail workload, Railway gave me the shortest operational path from a managed PostgreSQL instance to an application connection string. I did not need to provision a VPC, subnet, or database security group.&lt;/p&gt;
&lt;p&gt;That was the engineering tradeoff: less network control, but less infrastructure to operate for a small web application.&lt;/p&gt;
&lt;h2 id=&quot;see-the-setup-in-24-seconds&quot;&gt;See the setup in 24 seconds&lt;/h2&gt;
&lt;p&gt;The short walkthrough shows how little interface work is needed to get a PostgreSQL service running.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://youtu.be/2363rYrj_M0&quot;&gt;Watch the video walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;connect-django-with-database_url&quot;&gt;Connect Django with &lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Railway provides a &lt;code&gt;DATABASE_URL&lt;/code&gt; containing the host, port, database name, user, and password. In this project, &lt;code&gt;wagtailio/settings/base.py&lt;/code&gt; passes that value to &lt;code&gt;dj_database_url.config()&lt;/code&gt;, sets &lt;code&gt;ssl_require=True&lt;/code&gt;, and uses &lt;code&gt;conn_max_age=600&lt;/code&gt; so Django can reuse connections instead of opening a new one for every request.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;DATABASES = {
    &quot;default&quot;: dj_database_url.config(
        conn_max_age=600,
        ssl_require=True,
    )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The credential belongs in the deployment environment. It should never be committed to Git or copied into an article. A different Django project can use the same pattern, then adjust connection lifetime and pooling for its own traffic.&lt;/p&gt;
&lt;h2 id=&quot;measure-the-deployed-database&quot;&gt;Measure the deployed database&lt;/h2&gt;
&lt;p&gt;The snapshots below captured about &lt;strong&gt;$0.18 of usage so far&lt;/strong&gt; and a &lt;strong&gt;$0.55 end-of-month resource estimate&lt;/strong&gt; for this small database. These values are only a timestamped observation from my instance. Workload, storage, plan rules, and Railway pricing can all change the result.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/cost-main.width-1400.png&quot; alt=&quot;Railway usage panel for the database: $0.1815 so far and $0.55 estimated, with CPU, RAM, egress and volume charts&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/cost-main2.width-1400.png&quot; alt=&quot;Railway project cost table: memory, CPU, egress and volume lines adding up to $0.1815&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;The complete bill matters more than the resource estimate. Railway’s Hobby plan has a &lt;strong&gt;$5 monthly minimum with $5 of usage included&lt;/strong&gt; (still true when I checked in October 2026). In the invoice shown later, the amount due was &lt;strong&gt;$6.05 including Dutch VAT&lt;/strong&gt;. Always check the current plan and pricing rather than treating an old screenshot as a quote.&lt;/p&gt;
&lt;h2 id=&quot;region-placement-changed-query-latency&quot;&gt;Region placement changed query latency&lt;/h2&gt;
&lt;p&gt;When the database was farther from the application and its users, latency was noticeable. Moving it to Railway’s Amsterdam region reduced the network distance and made response time more consistent for this European workload.&lt;/p&gt;
&lt;p&gt;For another deployment, place the application and database in nearby regions, then measure the real request path. Provider choice cannot compensate for avoidable network distance.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/region.width-1400.png&quot; alt=&quot;Railway service settings showing the PostgreSQL database placed in the Amsterdam region&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;monitoring-and-billing-are-easy-to-read&quot;&gt;Monitoring and billing are easy to read&lt;/h2&gt;
&lt;p&gt;The service view puts CPU, memory, network traffic, storage, connections, and logs in one place. The workspace usage view then connects that activity to an estimated cost. It is enough visibility for this project without assembling several dashboards.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/monitoring.width-1400.png&quot; alt=&quot;Railway Postgres metrics tab: CPU, memory and public network traffic over one hour&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;p&gt;The invoice below is the useful reality check. It separates resource lines, the Hobby plan, included usage, and tax, so the total is not confused with the database-only estimate.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://media.oploy.eu/images/bill.width-1400.png&quot; alt=&quot;Railway invoice: disk, network, vCPU and memory lines, the $5 Hobby plan, $4.06 of included usage, 21% Dutch VAT, $6.05 due&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;In Railway, create a PostgreSQL service in the region closest to your application.&lt;/li&gt;
&lt;li&gt;Expose its &lt;code&gt;DATABASE_URL&lt;/code&gt; to the application service as an environment variable. Do not copy it into code.&lt;/li&gt;
&lt;li&gt;In Django:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install dj-database-url
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import dj_database_url

DATABASES = {
    &quot;default&quot;: dj_database_url.config(conn_max_age=600, ssl_require=True)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;Deploy, open one page, and check the Postgres metrics tab for connections and memory.&lt;/li&gt;
&lt;li&gt;Wait for the first invoice and compare it with the usage estimate.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;Two things surprised me. First, the database started in a region farther from the application and its users, and the latency was noticeable until I moved it to Amsterdam. Second, a database-only estimate of $0.55 said little about the bill: the invoice shown here was $6.05, mostly the plan minimum and VAT.&lt;/p&gt;
&lt;h2 id=&quot;when-i-would-use-this-pattern-again&quot;&gt;When I would use this pattern again&lt;/h2&gt;
&lt;p&gt;This pattern fits a small application that needs managed PostgreSQL, environment-based configuration, SSL, readable metrics, and a short deployment path.&lt;/p&gt;
&lt;p&gt;For regulated workloads, strict availability targets, complex private networking, or large database operations, I would compare backup and recovery guarantees, compliance, connection pooling, support, and high availability before choosing a platform.&lt;/p&gt;</content:encoded></item><item><title>Optimizing Reorder Points with a Noisy Simulator</title><link>https://notes.oploy.eu/inventory-simulation-optimization/</link><guid isPermaLink="true">https://notes.oploy.eu/inventory-simulation-optimization/</guid><description>A genetic algorithm picks reorder points for five products. When I re-measured its best answers with 200 runs, they were 11 to 15 percent worse than it said.</description><pubDate>Sat, 20 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in September 2025, when I finished the optimizer behind the &lt;a href=&quot;https://simopt.netlify.app/&quot;&gt;simulation-optimization demo&lt;/a&gt;. Rerun on 8 October 2026 with the same code; the numbers below are from that rerun.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation/&quot;&gt;inventory simulator&lt;/a&gt; tells you what one set of reorder points costs. The next question is which reorder points to choose. With five products and a noisy simulator there is no formula, so I put a genetic algorithm (GA) around the simulation. My earlier &lt;a href=&quot;https://notes.oploy.eu/ann-metamodel-simulation-optimization/&quot;&gt;ANN metamodel paper&lt;/a&gt; (&lt;a href=&quot;https://github.com/Mbehbahani/ANN-SMB-SemiExpSimOpt&quot;&gt;code&lt;/a&gt;) took the other route: learn a cheap model of the simulation first.&lt;/p&gt;
&lt;h2 id=&quot;the-setup&quot;&gt;The setup&lt;/h2&gt;
&lt;p&gt;Five products with fixed parameters. The GA only chooses the reorder point r of each one.&lt;/p&gt;















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Product&lt;/th&gt;&lt;th&gt;Policy&lt;/th&gt;&lt;th&gt;Customers/period&lt;/th&gt;&lt;th&gt;Lead time (mean)&lt;/th&gt;&lt;th&gt;Range for r&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;A&lt;/td&gt;&lt;td&gt;(s,S), S = 50, backorders&lt;/td&gt;&lt;td&gt;10&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;0 to 49&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;B&lt;/td&gt;&lt;td&gt;(r,Q), Q = 15, lost sales&lt;/td&gt;&lt;td&gt;8&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;0 to 69&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;C&lt;/td&gt;&lt;td&gt;(s,S), S = 40, backorders&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;0 to 39&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;D&lt;/td&gt;&lt;td&gt;(r,Q), Q = 20, lost sales&lt;/td&gt;&lt;td&gt;12&lt;/td&gt;&lt;td&gt;2.5&lt;/td&gt;&lt;td&gt;0 to 59&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;E&lt;/td&gt;&lt;td&gt;(s,S), S = 30, backorders&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;3.5&lt;/td&gt;&lt;td&gt;0 to 29&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;For an (r,Q) product the upper bound is the expected lead-time demand plus three standard deviations plus 25 percent.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-b52778bf8de8.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-b52778bf8de8.svg&quot; alt=&quot;The GA proposes reorder points, the simulator runs each candidate 3 times for 60 periods, the mean cost becomes its fitness, and selection, crossover and mutation produce the next generation until the cost stops improving&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GA settings are the API defaults: population 40, crossover rate 0.9, mutation rate 0.3 (adaptive), tournament of 2, elitism 2, 15 percent random immigrants per generation. It stops after at least 25 generations once the best cost has not improved for 10 generations (a change below 0.0005), or at the time limit.&lt;/p&gt;
&lt;h2 id=&quot;the-experiment&quot;&gt;The experiment&lt;/h2&gt;
&lt;p&gt;I ran the GA three times with different seeds. Then I re-measured each best solution with 200 fresh simulation runs, and did the same for the midpoint of every search range as a naive baseline.&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Run&lt;/th&gt;&lt;th&gt;Reorder points (A to E)&lt;/th&gt;&lt;th&gt;Cost the GA reported&lt;/th&gt;&lt;th&gt;Cost re-measured (200 runs)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Midpoints&lt;/td&gt;&lt;td&gt;24, 34, 19, 29, 14&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;399.2&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GA, seed 1&lt;/td&gt;&lt;td&gt;27, 6, 21, 11, 23&lt;/td&gt;&lt;td&gt;329.3&lt;/td&gt;&lt;td&gt;366.3&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GA, seed 2&lt;/td&gt;&lt;td&gt;27, 11, 28, 3, 19&lt;/td&gt;&lt;td&gt;322.9&lt;/td&gt;&lt;td&gt;360.2&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GA, seed 3&lt;/td&gt;&lt;td&gt;33, 13, 16, 14, 24&lt;/td&gt;&lt;td&gt;327.8&lt;/td&gt;&lt;td&gt;376.9&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Each GA run stopped on convergence after 27 to 39 generations, 1,052 to 1,512 evaluations and 7 to 9 seconds.&lt;/p&gt;
&lt;h2 id=&quot;what-the-numbers-say&quot;&gt;What the numbers say&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The GA’s own score is optimistic.&lt;/strong&gt; It judges each candidate on 3 noisy runs, so the winner is partly the candidate that got lucky draws. Re-measured, every winner was 11 to 15 percent more expensive than reported. The real gain over the naive midpoints is 6 to 10 percent, not the 18 percent the GA’s own numbers suggest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Different seeds, different answers, similar cost.&lt;/strong&gt; Product D’s reorder point came out as 11, 3 and 14. The cost surface is flat and noisy, so the GA stops on whichever plateau it finds first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The cost function pushes lost-sales products toward stockouts.&lt;/strong&gt; B and D end with low reorder points and fill rates of 36 to 51 percent. The model has no revenue, so losing a sale (cost 5) is cheaper than serving it (about 5.1). I explain this in the &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation/&quot;&gt;simulation note&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The seed does not make a run repeatable.&lt;/strong&gt; The GA seeds its own random choices, but every fitness evaluation calls the simulator with &lt;code&gt;base_seed=None&lt;/code&gt;. Calling the API twice with &lt;code&gt;&quot;seed&quot;: 1&lt;/code&gt; gave &lt;code&gt;[41, 3, 29, 7, 17]&lt;/code&gt; and &lt;code&gt;[39, 12, 34, 12, 20]&lt;/code&gt;. It also means two candidates are never compared on the same demand. The fix is common random numbers: pass the same seeds to every candidate’s replicates. That makes runs repeatable and the comparison fairer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;/simulate&lt;/code&gt; ignores its &lt;code&gt;seed&lt;/code&gt; field.&lt;/strong&gt; It is accepted and then dropped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My “30 seconds” change did nothing.&lt;/strong&gt; In September 2025 I raised the time limit from 20 to 30 seconds in &lt;code&gt;optimization.py&lt;/code&gt;, but the API request model still defaults to 20, so the API never used it. With these defaults it does not matter: every run converged in under 10 seconds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reporting the search score as the result.&lt;/strong&gt; The demo shows the GA’s own best cost. It should re-measure the final candidates with many more runs before showing a number.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;The code is not public yet; the API and the &lt;a href=&quot;https://simopt.netlify.app/&quot;&gt;demo&lt;/a&gt; are.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# One GA run (about 8 seconds). Expect different reorder points each time; see above.
curl -s -X POST https://api-simopt.oploy.eu/optimize \
  -H &quot;Content-Type: application/json&quot; -d &apos;{&quot;seed&quot;: 1}&apos;

# Re-measure a solution with 50 runs (the API&apos;s maximum per call).
curl -s -X POST https://api-simopt.oploy.eu/simulate \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;reorder_points&quot;: {&quot;Product A&quot;: 27, &quot;Product B&quot;: 6, &quot;Product C&quot;: 21, &quot;Product D&quot;: 11, &quot;Product E&quot;: 23},
       &quot;replicates&quot;: 50, &quot;horizon&quot;: 60}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Run the optimizer a few times and note &lt;code&gt;best_cost&lt;/code&gt; and &lt;code&gt;best_solution&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Re-measure each &lt;code&gt;best_solution&lt;/code&gt; with four calls to &lt;code&gt;/simulate&lt;/code&gt; at 50 replicates and average the &lt;code&gt;objective&lt;/code&gt; values.&lt;/li&gt;
&lt;li&gt;Compare: the re-measured cost should come out about 10 to 15 percent above &lt;code&gt;best_cost&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The fill rates of backordered products (A, C, E) are wrong until the fix in the &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation/&quot;&gt;simulation note&lt;/a&gt; is deployed. The idea of replacing the expensive simulator with a learned model is in &lt;a href=&quot;https://notes.oploy.eu/ann-metamodel-simulation-optimization/&quot;&gt;my ANN metamodel paper&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Simulating Inventory Policies with SimPy</title><link>https://notes.oploy.eu/inventory-simulation/</link><guid isPermaLink="true">https://notes.oploy.eu/inventory-simulation/</guid><description>A multi-product (r,Q) and (s,S) inventory simulation, what a single 60-period run hides, and a fill-rate bug I only found when I reran it.</description><pubDate>Thu, 21 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in August 2025, when I built the simulator behind the &lt;a href=&quot;https://siminv.netlify.app/&quot;&gt;inventory simulation demo&lt;/a&gt;. Rerun on 8 October 2026 with the same code: the tables below are from that rerun, and the bugs in What went wrong were found then.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Fixed reorder rules ignore variability, so they produce both overstock and stockouts at the same time. I wanted a small simulator that shows the cost of a reorder rule under random demand and random lead times, product by product, before trying to optimize anything.&lt;/p&gt;
&lt;h2 id=&quot;the-model&quot;&gt;The model&lt;/h2&gt;
&lt;p&gt;Each product follows exactly one policy: &lt;strong&gt;(r,Q)&lt;/strong&gt;, order a fixed quantity Q when the inventory position falls to r, or &lt;strong&gt;(s,S)&lt;/strong&gt;, order up to S. Customers arrive as a Poisson process, each one buys a random discrete amount, and every order arrives after an exponential lead time. A product either backorders unmet demand or loses it.&lt;/p&gt;
&lt;p&gt;Every review period the loop is the same:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-835bb8340fd8.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-835bb8340fd8.svg&quot; alt=&quot;One review period: receive orders that are due, draw demand, update stock and costs, and place an order if the inventory position is at or below the reorder point&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The inventory position is on hand plus outstanding orders. The cost per period is holding plus shortage plus ordering (a setup cost per order and a cost per unit), averaged over the horizon. The engine is SimPy, with NumPy and Python’s &lt;code&gt;random&lt;/code&gt; module for the draws (both seeded together), behind a small FastAPI service.&lt;/p&gt;
&lt;h2 id=&quot;what-i-ran&quot;&gt;What I ran&lt;/h2&gt;
&lt;p&gt;Two products from the demo’s defaults, 60 periods each, 50 seeded runs per reorder point:&lt;/p&gt;


























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Product&lt;/th&gt;&lt;th&gt;Policy&lt;/th&gt;&lt;th&gt;Demand&lt;/th&gt;&lt;th&gt;Lead time (mean)&lt;/th&gt;&lt;th&gt;Unmet demand&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;A&lt;/td&gt;&lt;td&gt;(s,S), S = 50&lt;/td&gt;&lt;td&gt;10 customers/period, 0.5, 1 or 1.5 units each&lt;/td&gt;&lt;td&gt;3 periods&lt;/td&gt;&lt;td&gt;backordered&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;B&lt;/td&gt;&lt;td&gt;(r,Q), Q = 15&lt;/td&gt;&lt;td&gt;8 customers/period, 1, 2 or 3 units each&lt;/td&gt;&lt;td&gt;2 periods&lt;/td&gt;&lt;td&gt;lost&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Both use holding 1, shortage 5, setup 32 and unit cost 3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Product A&lt;/strong&gt;&lt;/p&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;r&lt;/th&gt;&lt;th&gt;Mean cost/period&lt;/th&gt;&lt;th&gt;SD&lt;/th&gt;&lt;th&gt;Min to max&lt;/th&gt;&lt;th&gt;Fill rate&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;10&lt;/td&gt;&lt;td&gt;96.4&lt;/td&gt;&lt;td&gt;25.6&lt;/td&gt;&lt;td&gt;61.0 to 190.9&lt;/td&gt;&lt;td&gt;0.62&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;20&lt;/td&gt;&lt;td&gt;87.8&lt;/td&gt;&lt;td&gt;18.3&lt;/td&gt;&lt;td&gt;63.4 to 144.6&lt;/td&gt;&lt;td&gt;0.66&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;30&lt;/td&gt;&lt;td&gt;79.4&lt;/td&gt;&lt;td&gt;14.4&lt;/td&gt;&lt;td&gt;59.4 to 131.1&lt;/td&gt;&lt;td&gt;0.75&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;40&lt;/td&gt;&lt;td&gt;79.1&lt;/td&gt;&lt;td&gt;8.5&lt;/td&gt;&lt;td&gt;66.4 to 101.1&lt;/td&gt;&lt;td&gt;0.84&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Product B&lt;/strong&gt;&lt;/p&gt;















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;r&lt;/th&gt;&lt;th&gt;Mean cost/period&lt;/th&gt;&lt;th&gt;SD&lt;/th&gt;&lt;th&gt;Min to max&lt;/th&gt;&lt;th&gt;Fill rate&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;10&lt;/td&gt;&lt;td&gt;84.0&lt;/td&gt;&lt;td&gt;4.1&lt;/td&gt;&lt;td&gt;72.6 to 93.2&lt;/td&gt;&lt;td&gt;0.37&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;20&lt;/td&gt;&lt;td&gt;88.0&lt;/td&gt;&lt;td&gt;3.9&lt;/td&gt;&lt;td&gt;77.7 to 96.9&lt;/td&gt;&lt;td&gt;0.60&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;30&lt;/td&gt;&lt;td&gt;91.7&lt;/td&gt;&lt;td&gt;3.8&lt;/td&gt;&lt;td&gt;80.7 to 99.2&lt;/td&gt;&lt;td&gt;0.73&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;40&lt;/td&gt;&lt;td&gt;94.9&lt;/td&gt;&lt;td&gt;3.5&lt;/td&gt;&lt;td&gt;83.5 to 102.8&lt;/td&gt;&lt;td&gt;0.78&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;50&lt;/td&gt;&lt;td&gt;99.2&lt;/td&gt;&lt;td&gt;3.2&lt;/td&gt;&lt;td&gt;89.4 to 109.3&lt;/td&gt;&lt;td&gt;0.84&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;(Product A’s fill rates are corrected; see What went wrong.)&lt;/p&gt;
&lt;h2 id=&quot;what-the-numbers-say&quot;&gt;What the numbers say&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;One run is not an answer.&lt;/strong&gt; At r = 10, a single 60-period run of Product A costs anywhere from 61 to 191 per period. Any comparison of reorder points needs many runs, or the same random demand for both options.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Product B gets cheaper the less it sells.&lt;/strong&gt; Its cost rises with r, and the cheapest rule serves only 37 percent of demand. That is the cost function talking, not inventory theory: there is no revenue in it. Serving one unit costs about 5.1 (unit cost 3, setup 32 / 15, plus holding), while losing it costs 5. An optimizer will happily learn to stop selling. To fix the model, add the lost margin to the shortage cost of lost-sales products, or require a minimum fill rate. I wrote about the same trap in &lt;a href=&quot;https://notes.oploy.eu/optimization-starts-with-the-cost-function/&quot;&gt;testing a cost function before optimizing&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The fill rate was wrong for backordered products.&lt;/strong&gt; A backorder stays in negative stock, and in every later period the whole backlog was counted again as new unmet demand. Product A’s fill rate came out as 0.002 at r = 10, and the optimizer’s copy of the engine reported negative fill rates for another product. The cost is fine (a backorder cost per unit per period is a valid choice); only the metric was broken. The fix is one line in the demand step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;self.unmet_demand += min(shortfall, demand)  # count only this period&apos;s unmet demand
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With it, Product A’s fill rate is 0.62 to 0.84 instead of 0.002 to 0.74.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The API failed when history was off.&lt;/strong&gt; &lt;code&gt;run()&lt;/code&gt; returns the summary on its own when &lt;code&gt;record_history&lt;/code&gt; is false, and a &lt;code&gt;{&quot;summary&quot;, &quot;history&quot;}&lt;/code&gt; pair when it is true, but the API always expects the pair. So &lt;code&gt;POST /simulate&lt;/code&gt; with &lt;code&gt;&quot;record_history&quot;: false&lt;/code&gt; returns HTTP 400. Returning the same shape in both cases fixes it.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;The code is not public yet, but the API behind the demo is, and it gives the same numbers as my local runs (same seed, same result to the last decimal). One run of Product A at r = 20:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -s -X POST https://api-siminv.oploy.eu/simulate \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;horizon&quot;: 60, &quot;record_history&quot;: true, &quot;random_seed&quot;: 1,
       &quot;products&quot;: [{&quot;name&quot;: &quot;Product A&quot;, &quot;reorder_point&quot;: 20, &quot;reorder_up_to&quot;: 50,
         &quot;demand_rate&quot;: 10, &quot;demand_sizes&quot;: [0.5, 1, 1.5],
         &quot;demand_probs&quot;: [0.3333333333, 0.3333333333, 0.3333333334],
         &quot;lead_time_mean&quot;: 3}]}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It returns &lt;code&gt;&quot;average_cost_per_period&quot;: 85.54166666666667&lt;/code&gt; for seed 1.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Keep &lt;code&gt;&quot;record_history&quot;: true&lt;/code&gt; (see above).&lt;/li&gt;
&lt;li&gt;Loop &lt;code&gt;random_seed&lt;/code&gt; from 0 to 49 and average &lt;code&gt;average_cost_per_period&lt;/code&gt;: that is one row of the tables.&lt;/li&gt;
&lt;li&gt;Ignore the reported fill rate for backordered products until the fix above is deployed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Or move the sliders in the &lt;a href=&quot;https://siminv.netlify.app/&quot;&gt;demo&lt;/a&gt;. The next step, letting a genetic algorithm choose the reorder points, is in &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation-optimization/&quot;&gt;optimizing reorder points with a noisy simulator&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Replacing a Slow Simulation with a Neural Network Metamodel</title><link>https://notes.oploy.eu/ann-metamodel-simulation-optimization/</link><guid isPermaLink="true">https://notes.oploy.eu/ann-metamodel-simulation-optimization/</guid><description>From my Master&apos;s thesis, published in 2025: an ANN metamodel against Kriging for simulations that take minutes per run, and where the comparison stops.</description><pubDate>Sat, 10 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Written in May 2025. The work is from my Master’s thesis (2018); the article was accepted and published in 2025 after a long editorial process.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Some simulations take two to five minutes per run. That is too slow to call thousands of times inside an optimizer, but not slow enough to justify building a metamodel up front and trusting it blindly. Inventory models are a typical case (I wrote about a &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation/&quot;&gt;SimPy inventory simulation&lt;/a&gt; separately). The paper calls these semi-expensive problems, and asks which surrogate to use for them: an artificial neural network (ANN) or Kriging.&lt;/p&gt;
&lt;h2 id=&quot;the-method&quot;&gt;The method&lt;/h2&gt;
&lt;p&gt;The algorithm (ANN-SMB, a semi-metamodel-based method) works in two phases. This is the flow in &lt;code&gt;main_core_ANN.m&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/mermaid-589a965fa7b9.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/mermaid-589a965fa7b9.svg&quot; alt=&quot;Simulate 50 starting points, train an ANN, validate it; while it is not valid, a PSO step on the real simulation adds 10 points and the ANN is retrained; once valid, PSO searches the ANN and the best point is checked with the real simulation&quot; class=&quot;diagram-dark&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Start:&lt;/strong&gt; the corners of the search box plus a maximin Latin hypercube, 50 points in total, each simulated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 1&lt;/strong&gt; explores with the real simulation: a particle swarm step adds 10 new simulated points per round, and the ANN is retrained, for at most three rounds or until the ANN passes validation (leave-one-out cross-validation with a studentized prediction error).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 2&lt;/strong&gt; exploits: the swarm searches the trained ANN instead of the simulation, and only the final point is simulated again.&lt;/li&gt;
&lt;li&gt;The ANN is a feed-forward network with one tan-sigmoid hidden layer (15 neurons in the code) and a linear output. The swarm is the paper’s spatial-hole PSO variant (SH-PSO).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same framework with Kriging in place of the ANN is the comparison.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;p&gt;On an (s,S) inventory model:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th&gt;ANN-SMB&lt;/th&gt;&lt;th&gt;Kriging-SMB&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Average objective value&lt;/td&gt;&lt;td&gt;595.28&lt;/td&gt;&lt;td&gt;603.71&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Standard deviation&lt;/td&gt;&lt;td&gt;10.61&lt;/td&gt;&lt;td&gt;14.99&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Function evaluations&lt;/td&gt;&lt;td&gt;262.5&lt;/td&gt;&lt;td&gt;264&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;On five analytical test functions, the ANN version was better on Sphere, Griewank, Schaffer F6 and Rosenbrock, and Kriging on Rastrigin, with 5 to 10 percent fewer function evaluations for the ANN.&lt;/p&gt;
&lt;h2 id=&quot;what-this-does-and-does-not-show&quot;&gt;What this does and does not show&lt;/h2&gt;
&lt;p&gt;The difference on the inventory model is &lt;strong&gt;not statistically significant&lt;/strong&gt; (p = 0.913): solution quality is equivalent, and the ANN’s advantage is a smaller spread and slightly fewer evaluations. The tests go up to three decision variables, on continuous problems with low noise, and the result is sensitive to the number of hidden neurons (about 6 percent).&lt;/p&gt;
&lt;p&gt;That boundary matters. When I later &lt;a href=&quot;https://notes.oploy.eu/inventory-simulation-optimization/&quot;&gt;optimized reorder points for five products with a GA&lt;/a&gt;, the problem was discrete, five-dimensional and very noisy: exactly the case this paper does not cover.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The README lists the wrong parameters.&lt;/strong&gt; It says to tune &lt;code&gt;idp&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt;, &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;h&lt;/code&gt; and &lt;code&gt;epsilon&lt;/code&gt; in &lt;code&gt;main_core_ANN.m&lt;/code&gt;; those names do not appear there. The real settings are at the top of the file (see below).&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;You need MATLAB 8.4 or later with the Statistics and Machine Learning Toolbox.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-matlab&quot;&gt;% clone https://github.com/Mbehbahani/ANN-SMB-SemiExpSimOpt, then in MATLAB:
cd Main
main_core_ANN
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;It starts on the Griewank test function, &lt;code&gt;f=@griewank&lt;/code&gt;, with bounds -8 to 8 in two dimensions.&lt;/li&gt;
&lt;li&gt;For the inventory model, switch to the commented lines &lt;code&gt;f=@operationfun&lt;/code&gt; with &lt;code&gt;Lower_Bound=[450,750]&lt;/code&gt; and &lt;code&gt;Upper_Bound=[750,1050]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Num_o_Design_points=50&lt;/code&gt; sets the first design, and &lt;code&gt;Num_o_Samples=1&lt;/code&gt; sets the replications per point.&lt;/li&gt;
&lt;li&gt;Other test functions (&lt;code&gt;rosenbrock.m&lt;/code&gt;, &lt;code&gt;rastr.m&lt;/code&gt;, &lt;code&gt;schaffer2.m&lt;/code&gt;, &lt;code&gt;funksphere.m&lt;/code&gt;) are in the same folder.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The article: &lt;a href=&quot;https://doi.org/10.71720/joie.2025.1183309&quot;&gt;An artificial neural network metamodel to solve semi-expensive simulation optimization problems: a comparative study&lt;/a&gt;, Journal of Industrial Engineering, 2025.&lt;/p&gt;</content:encoded></item><item><title>Visualizing a Bi-Objective Repair-Shop Schedule</title><link>https://notes.oploy.eu/repair-shop-schedule/</link><guid isPermaLink="true">https://notes.oploy.eu/repair-shop-schedule/</guid><description>A schedule from my repair-shop scheduling work, with the model objectives, constraints, solution methods, and source code behind it.</description><pubDate>Sat, 12 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;First written in April 2025. The work behind it was published in the Decision Analytics Journal in 2026.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This chart is the schedule the &lt;a href=&quot;https://opt.oploy.eu/&quot;&gt;web demo&lt;/a&gt; returns with its default parameters: six cars, four repair stations and a seven-hour shift, solved for the lowest idle and overtime cost. Each row is a station, each block is one car’s repair at that station, and its position shows the start and finish time. Hatched blocks are paid time with nothing to do, and anything right of the red line is overtime. The chart turns solver output into a sequence that an operator can inspect.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://notes.oploy.eu/diagrams/repair-shop-schedule.svg&quot; class=&quot;diagram-link&quot;&gt;&lt;img src=&quot;https://notes.oploy.eu/diagrams/repair-shop-schedule.svg&quot; alt=&quot;Gantt chart of the default demo schedule: four stations as rows, six cars as coloured blocks over about eight hours, a red end-of-shift line at seven hours, station 1 running 70 minutes and station 4 running 45 minutes into overtime, and stations 2 and 3 with 80 and 55 idle minutes&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-optimization-problem&quot;&gt;The optimization problem&lt;/h2&gt;
&lt;p&gt;The model is bi-objective. One objective reduces total flow time so jobs move through the system faster. The other reduces idle-resource and overtime cost. These objectives can conflict: compressing a schedule may improve completion time while creating expensive resource peaks, and a cheaper resource plan may delay jobs.&lt;/p&gt;
&lt;p&gt;The constraints enforce feasible operation order, resource assignment, processing duration, and non-overlap. A valid solution must place every required operation on an eligible resource without allowing two operations to use the same capacity at the same time.&lt;/p&gt;
&lt;h2 id=&quot;from-mathematical-model-to-comparable-schedules&quot;&gt;From mathematical model to comparable schedules&lt;/h2&gt;
&lt;p&gt;I implemented exact mixed-integer programming models and a modified NSGA-II search so I could compare solver-based and metaheuristic solutions. The multi-objective output is a set of non-dominated schedules rather than one unexplained answer. A decision maker can compare flow time with idle and overtime cost, then inspect the selected schedule in a chart like the one above.&lt;/p&gt;
&lt;p&gt;Validation on real operational data produced about a &lt;strong&gt;42 percent reduction in total flow time&lt;/strong&gt; and a &lt;strong&gt;47 percent reduction in idle and overtime costs&lt;/strong&gt; relative to the comparison case reported in the study. Those figures belong to that dataset and experimental design. They are not universal performance claims.&lt;/p&gt;
&lt;h2 id=&quot;reuse-the-technical-work&quot;&gt;Reuse the technical work&lt;/h2&gt;
&lt;p&gt;The public repository contains the official research code, including MATLAB, GAMS, OR-Tools, and FastAPI-related implementations. How the FastAPI version is deployed is in &lt;a href=&quot;https://notes.oploy.eu/deploying-fastapi-on-railway/&quot;&gt;deploying FastAPI on Railway&lt;/a&gt;. If you adapt it, replace the operation times, eligible resources, precedence rules, capacity assumptions, and objective weights with your own system data. Then validate feasibility and objective units before comparing algorithms.&lt;/p&gt;
&lt;h2 id=&quot;how-to-repeat-this&quot;&gt;How to repeat this&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/Mbehbahani/RepairShop-milp&quot;&gt;RepairShop-milp repository&lt;/a&gt; has three entry points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Gams/Flow_time.gms&lt;/code&gt; and &lt;code&gt;Gams/Idle_Over_time_cost.gms&lt;/code&gt;: the MILP, one file per objective (solved with CPLEX).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MATLAB/Optimizer.m&lt;/code&gt;: the modified NSGA-II for larger instances.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ORtools/Idle_Overtime.py&lt;/code&gt;: the CP-SAT version behind the &lt;a href=&quot;https://opt.oploy.eu/&quot;&gt;web demo&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fastest check needs no install: open the &lt;a href=&quot;https://opt.oploy.eu/&quot;&gt;web demo&lt;/a&gt;, solve the sample day, and read the Gantt chart the same way as the one above. To compare with the paper, use Instance 1 from its Table 2 (28 cars, 49 repair procedures): the manual schedule had $469 of idle and overtime cost and 6,188 minutes of flow time; NSGA-II reached $267 and 3,925 minutes.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://doi.org/10.1016/j.dajour.2026.100682&quot;&gt;Read the peer-reviewed article&lt;/a&gt;. The code behind it is in the &lt;a href=&quot;https://github.com/Mbehbahani/RepairShop-milp&quot;&gt;RepairShop-milp repository&lt;/a&gt;.&lt;/p&gt;</content:encoded></item></channel></rss>