DataModel updates, sample data ingress. Sample views

main
ookjosh 2025-11-10 07:47:55 -07:00
parent 6c6a0190e0
commit 0e491fd8fd
12 changed files with 572 additions and 29 deletions

30
README.md 100644
View File

@ -0,0 +1,30 @@
# Running Server
`python manage.py runserver`
# Testing
`python manage.py test [appname]`
- E.g., `python manage.py test datavis`
# Populating with test data
Reset database (normal sql client):
```sql
DROP DATABASE SouthwestChickenTest;
CREATE DATABASE SouthwestChickenTest;
```
Then `python manage.py init_test_data`
# Useful debugging for querying
`python manage.py shell` gives you a shell where you can run queries like `Measurements.objects.all()` or whatever filtering you'd like. See [documentation](https://docs.djangoproject.com/en/5.2/topics/db/queries) for reference.
**Note**: To print the query that django is using (e.g., to compare to a manual query to figure out how to translate something to the django DSL):
```py
# Example printing equivalent SQL query
q = Group.objects.filter(group2type__type__name="Windrow", child_group__parent_id=1)
print(q.query)
```

View File

@ -1,3 +1,12 @@
from django.contrib import admin
from .models import Group, Group2Type, GroupParent, GroupType, Measurement, Node, Node2Group
# Register your models here.
admin.site.register(Group)
admin.site.register(GroupType)
admin.site.register(Group2Type)
admin.site.register(Node)
admin.site.register(Node2Group)
admin.site.register(GroupParent)
admin.site.register(Measurement)

View File

@ -0,0 +1,148 @@
import datetime
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction, IntegrityError
from datavis.models import *
class Command(BaseCommand):
help = "Initialize database with test data"
def add_arguments(self, parser):
parser.add_argument("part", nargs="+", type=int)
pass
@transaction.atomic
def handle(self, *args, **options):
try:
with transaction.atomic():
try:
self.add_robot_hands_groups()
except Exception as e:
raise CommandError("Failed to add robot hands group", e)
try:
self.add_demo_site()
except Exception as e:
raise CommandError("Failed to add demo turkey site", e)
try:
self.add_virtual_site()
except Exception as e:
raise CommandError("Failed to add virtual site data", e)
except IntegrityError:
raise CommandError("Failed to add virtual site data", e)
self.stdout.write(
self.style.SUCCESS("Successfully generated sample data")
)
def add_robot_hands_groups(self):
"""
Site: "Wisconsin Turkey"
- [ ] Windrows: 2 Windrows
- [ ] 10 temperature sites each
"""
robot_hands = Group()
robot_hands.name = "Robot Hands"
robot_hands.save()
incomplete_probes = Group()
incomplete_probes.name = "Incomplete Probes"
incomplete_probes.save()
incomplete_base_stations = Group()
incomplete_base_stations.name = "Incomplete Base Stations"
incomplete_base_stations.save()
complete_probes = Group()
complete_probes.name = "Probes Ready To Deploy"
complete_probes.save()
complete_base_stations = Group()
complete_base_stations.name = "Base Stations Ready To Deploy"
complete_base_stations.save()
def add_demo_site(self):
demo_site = Group()
demo_site.name = "Wisconsin Turkey"
demo_site.save()
windrow_1 = Group()
windrow_1.name = "Windrow 1"
windrow_1.save()
windrow_2 = Group()
windrow_2.name = "Windrow 2"
windrow_2.save()
for i in range(10):
temperature_site = Group()
temperature_site.name = f"{windrow_1.name} - Site {i+1}"
temperature_site.save()
for i in range(10):
temperature_site = Group()
temperature_site.name = f"{windrow_2.name} - Site {i+1}"
temperature_site.save()
def add_virtual_site(self):
"""
10 virtual nodes
- [ ] 1 virtual base station
- [ ] Virtual Site
- [ ] Virtual Windrow
- [ ] 10 Virtual temperature sites
- [ ] 2 weeks of sample data for all nodes
"""
virtual_site = Group()
virtual_site.name = "Virtual Site"
virtual_site.save()
windrow_1 = Group()
windrow_1.name = "Windrow 1"
windrow_1.save()
temperature_sites: list[Group] = []
for i in range(10):
temperature_site = Group()
temperature_site.name = f"{windrow_1.name} - Site {i+1}"
temperature_site.save()
temperature_sites.append(temperature_site)
virtual_base_station = Node()
virtual_base_station.friendly_name = "Virtual Base Station 1"
virtual_base_station.hardware_id = "1"
virtual_base_station.save()
NUM_VIRTUAL_NODES = 10
created_nodes: list[Node] = []
for i in range(NUM_VIRTUAL_NODES):
virtual_node = Node()
virtual_node.friendly_name = f"Virtual Probe {i + 1}"
virtual_node.save()
created_nodes.append(virtual_node)
SAMPLES_PER_DAY = 4
NUMBER_OF_DAYS = 14
for i in range(SAMPLES_PER_DAY * NUMBER_OF_DAYS):
for node_index in range(NUM_VIRTUAL_NODES):
measurement = Measurement()
measurement.source_node = created_nodes[node_index]
measurement.reporting_node = virtual_base_station
measurement.associated_group = temperature_sites[node_index]
# Every 6 hours
measurement.collection_time = datetime.datetime.now() + datetime.timedelta(seconds=6 * 3600 * i)
# Slight offset
measurement.server_received_time = datetime.datetime.now() + datetime.timedelta(seconds=6.25 * 3600 * i)
measurement.probe_temperature = 1.0
measurement.temperature_18_inch = 1.0
measurement.temperature_36_inch = 1.0
measurement.ambient_temperature = 1.0
measurement.device_temperature = 1.0
measurement.barometric_pressure = 1.0
measurement.relative_humidity = 1.0
measurement.accelerometer_x = 1.0
measurement.accelerometer_y = 1.0
measurement.accelerometer_z = 1.0
measurement.battery_charge_percent = 50.0
measurement.battery_voltage = 3.0
measurement.remaining_battery_capacity = 0.5
measurement.save()

View File

@ -0,0 +1,90 @@
import logging
from .models import Group, GroupParent, Node, Node2Group
def get_child_nodes(parent: Group):
relationships = list(GroupParent.objects.all())
node2group = list(Node2Group.objects.all())
result = []
for node in node2group:
if node.group.pk == parent.pk:
result.append(node.node.pk)
children = [rel.child for rel in relationships if rel.parent.pk == parent.pk]
for child in children:
result.extend(get_child_nodes(child))
return result
def get_customer_site_application_data(site_id: int) -> dict | None :
"""
Composes relationships into logical application format, for a given customer
site_id: Primary key int for the group
"""
# Filter by groups of type site with name site_name
site_candidates = Group.objects.filter(group2type__type__name="Site", id=site_id)
if len(site_candidates) != 1:
if len(site_candidates) == 0:
logging.error(f"Requested customer site information for a site that doesn't exist: {site_id}")
else:
logging.fatal(f"Unique primary key invariant not upheld for Groups! Primary key: {site_id}")
return None
result = {
"site": site_candidates[0],
"windrows": {
# {
# "name": "1",
# "status": "status_enum",
# "temperature_sites": {
# "id": {
# "name": "t1",
# "assigned_probe": "1",
# "measurements": [],
# }
# }
# }
},
"spare_nodes": [],
}
windrows = Group.objects.filter(group2type__type__name="Windrow", child_group__parent_id=site_id)
for windrow in windrows:
new_windrow = {"id": windrow.pk, "name": windrow.name, "temperature_sites": {}}
temperature_sites = Group.objects.filter(group2type__type__name="Temperature Site", child_group__parent_id=windrow.pk)
for t_site in temperature_sites:
nodes = Node.objects.filter(node2group__group_id=t_site.pk)
if len(nodes) <= 1:
# Do something
new_windrow["temperature_sites"][t_site.pk] = {
"name": t_site.name,
"id": t_site.pk,
"parent_windrow": windrow.pk,
"assigned_probe": None if len(nodes) == 0 else nodes[0],
"measurements": []
}
else:
logging.fatal("Invariant not upheld: Multiple nodes in temperature site")
result["windrows"][windrow.pk] = new_windrow
return result
def get_staff_application_data():
"""
Composes relationships into logical application format, across all data for staff viewing
"""
result = {
"sites": [],
}

View File

@ -1,3 +1,87 @@
from django.db import models
# Create your models here.
class Node(models.Model):
friendly_name = models.CharField(max_length = 255)
hardware_id = models.CharField(max_length = 128)
def __str__(self):
return f"{self.friendly_name} - {self.hardware_id}"
class Group(models.Model):
name = models.CharField(max_length = 255)
def __str__(self):
return self.name
class Node2Group(models.Model):
node = models.ForeignKey(to=Node, on_delete=models.CASCADE)
group = models.ForeignKey(to=Group, on_delete=models.CASCADE)
def __str__(self):
return f"{self.node.friendly_name}:{self.group.name}"
class GroupType (models.Model):
"""
Application-level discriminating field for what a "group" is
"""
name = models.CharField(max_length = 255)
def __str__(self):
return self.name
class GroupParent(models.Model):
"""
Groups are allowed to have multiple parents, no constraints on that.
TODO: Enforce child != parent
"""
child = models.ForeignKey(to=Group, on_delete=models.CASCADE, related_name='child_group')
parent = models.ForeignKey(to=Group, on_delete=models.CASCADE, related_name='parent_group')
def __str__(self):
return f"{self.child.name}<{self.parent.name}"
class Group2Type(models.Model):
group = models.ForeignKey(to=Group, on_delete=models.CASCADE)
type = models.ForeignKey(to=GroupType, on_delete=models.CASCADE)
def __str__(self):
return f"{self.group.name}:{self.type.name}"
class Meta:
"""
A group can only be one type
"""
constraints = [
models.UniqueConstraint(fields=['group', 'type'], name='unique_group_type')
]
class Measurement(models.Model):
source_node = models.ForeignKey(to=Node, on_delete=models.DO_NOTHING, related_name='measurement_source_node')
collection_time = models.DateTimeField()
server_received_time = models.DateTimeField()
probe_temperature = models.FloatField()
temperature_18_inch = models.FloatField()
temperature_36_inch = models.FloatField()
ambient_temperature = models.FloatField()
device_temperature = models.FloatField()
barometric_pressure = models.FloatField()
relative_humidity = models.FloatField()
accelerometer_x = models.FloatField()
accelerometer_y = models.FloatField()
battery_charge_percent = models.FloatField()
battery_voltage = models.FloatField()
remaining_battery_capacity = models.FloatField()
# Node (base station, generally) that ultimately sent the server this measurement.
reporting_node = models.ForeignKey(to=Node, on_delete=models.DO_NOTHING, related_name='measurement_reporting_node')
# Associated group for this measurement (generally a "temperature site")
# ~~Nullable because probes sitting somewhere may not be part of a group~~
# Not nullable because we always want a node parented by some group and this way we have
# better history for where a measurement value was taken.
associated_group = models.ForeignKey(to=Group, on_delete=models.DO_NOTHING, related_name='measurement_associated_group')
class Meta:
unique_together = (('source_node', 'collection_time'),)

View File

@ -2,34 +2,38 @@
<main>
<article>
<h1>Summary</h1>
<table>
<thead>
<table
class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
>
<tr>
<th>Measurement Time</th>
<th>Temperature (18")</th>
<th>Temperature (36")</th>
<th>Alerts</th>
<th scope="col" class="px-6 py-3">Measurement Time</th>
<th scope="col" class="px-6 py-3">Device ID</th>
<th scope="col" class="px-6 py-3">Temperature (18")</th>
<th scope="col" class="px-6 py-3">Temperature (36")</th>
<th scope="col" class="px-6 py-3">Battery Voltage</th>
</tr>
</thead>
<tbody>
<tr>
<td>2025-01-01 00:00</td>
<td>35 C</td>
<td>38 C</td>
<td></td>
</tr>
<tr>
<td>2025-01-01 00:00</td>
<td>35 C</td>
<td>38 C</td>
<td></td>
</tr>
<tr>
<td>2025-01-01 00:00</td>
<td>35 C</td>
<td>38 C</td>
<td></td>
<tbody class="">
{% for measurement in measurements %}
<tr
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"
>
<td
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
{{measurement.received_time}}
</td>
<td class="px-6 py-4">{{measurement.device_id}}</td>
<td class="px-6 py-4">{{measurement.temp18}} C</td>
<td class="px-6 py-4">
{{measurement.ambient_temperature}} C
</td>
<td class="px-6 py-4">{{measurement.battery_voltage}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>

View File

@ -0,0 +1,70 @@
{% extends "datavis/index.html" %} {% block content %}
<main>
<article>
<h1>Site - {{customer.site.name}}</h1>
<section class="flex gap-3">
<section class="flex-1">
{% for windrow_id, windrow in customer.windrows.items %}
<a href="?row={{windrow_id}}"{% if windrow_id == selected_windrow.id %}class="font-bold"{% endif %}>{{windrow.name}}</a>
{% endfor %}
</section>
<section class="flex-1">
{% for id, temp_site in windrow_temperature_sites.items %}
<a href="?row={{temp_site.parent_windrow}}&temp_site={{temp_site.id}}">{{temp_site.name}}</a>
{% endfor %}
</section>
<section class="flex-1">
{{selected_temperature_site.assigned_probe.friendly_name}}
{{selected_temperature_site.measurements}}
</section>
<section class="flex-1"></section>
</section>
<table
class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
>
<tr>
<th scope="col" class="px-6 py-3">Measurement Time</th>
<th scope="col" class="px-6 py-3">Device ID</th>
<th scope="col" class="px-6 py-3">Temperature (18")</th>
<th scope="col" class="px-6 py-3">Temperature (36")</th>
<th scope="col" class="px-6 py-3">Battery Voltage</th>
</tr>
</thead>
<tbody class="">
{% for measurement in measurements %}
<tr
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"
>
<td
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
{{measurement.received_time}}
</td>
<td class="px-6 py-4">{{measurement.device_id}}</td>
<td class="px-6 py-4">{{measurement.temp18}} C</td>
<td class="px-6 py-4">
{{measurement.ambient_temperature}} C
</td>
<td class="px-6 py-4">{{measurement.battery_voltage}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
<article>
<h1>Graphics</h1>
<section>
<svg viewBox="0 0 500 500">
<rect x="0" width="500" y="0" height="500" fill="#aaa" />
</svg>
</section>
</article>
</main>
{% endblock content %}

View File

@ -1,3 +1,63 @@
from django.test import TestCase
from .models import Group, Group2Type, GroupParent, GroupType, Node, Node2Group
from .model_util import get_child_nodes
# Create your tests here.
class NodeGroupingTests(TestCase):
# Test functions must start with `test_`
def test_node_relationships_work(self):
node = Node(friendly_name='Node A')
node2 = Node(friendly_name='Node B')
node.save()
node2.save()
group_nm = Group(name="New Mexico")
group_nm.save()
group_josh = Group(name="Josh")
group_josh.save()
group_sill = Group(name="Windowsill")
group_sill.save()
group_not_in_hierarchy = Group(name="Needs Charging")
group_not_in_hierarchy.save()
association = Node2Group(node=node, group=group_sill)
association.save()
association = Node2Group(node=node, group=group_not_in_hierarchy)
association.save()
association = Node2Group(node=node, group=group_josh)
association.save()
type_site = GroupType(name="Site")
type_site.save()
type_windrow = GroupType(name="Windrow")
type_windrow.save()
type_temp_site= GroupType(name="Temperature Site")
type_temp_site.save()
type_arbitrary = GroupType(name="Arbitrary Group")
type_arbitrary.save()
association = Group2Type(group=group_nm, type=type_site)
association.save()
association = Group2Type(group=group_josh, type=type_windrow)
association.save()
association = Group2Type(group=group_sill, type=type_temp_site)
association.save()
association = Group2Type(group=group_not_in_hierarchy, type=type_arbitrary)
association.save()
association = GroupParent(child=group_josh, parent=group_nm)
association.save()
association = GroupParent(child=group_sill, parent=group_josh)
association.save()
children = get_child_nodes(group_nm)
# Both nodes are children of "NM"
self.assertIs(len(children), 2)
# But only one node is a child of "arbitrary"
children = get_child_nodes(group_not_in_hierarchy)
self.assertIs(len(children), 1)

View File

@ -4,5 +4,6 @@ from . import views
urlpatterns = [
path("", views.index, name="dashboard_home"),
path("livedemo", views.index, name="livedemo")
path("livedemo", views.guest_demo, name="livedemo"),
path("site/<int:site_id>", views.site_view, name="site_view")
]

View File

@ -1,5 +1,9 @@
from django.shortcuts import render
from .model_util import get_customer_site_application_data
from .models import Measurement
# Create your views here.
def index(request):
context = {}
@ -9,6 +13,34 @@ def guest_demo(request):
"""
Loads safe public data and presents a read-only view into it
"""
context = {}
measurements = Measurement.objects.order_by("time")[2000:2250]
context = {
"measurements": measurements
}
return render(request, "datavis/dashboard_home.html", context)
def site_view(request, site_id):
customer_data = get_customer_site_application_data(site_id)
if request.method == "GET":
params = request.GET
selected_windrow_id = params.get("row", None)
temperature_sites = [] if selected_windrow_id is None or len(customer_data["windrows"]) == 0 else customer_data["windrows"][int(selected_windrow_id)]["temperature_sites"]
selected_temp_site_id = params.get("temp_site", None)
selected_temp_site = None if selected_temp_site_id is None or len(temperature_sites) == 0 else temperature_sites[int(selected_temp_site_id)]
context = {
"customer": customer_data,
"selected_windrow": selected_windrow_id,
"windrow_temperature_sites": temperature_sites,
"selected_temperature_site": selected_temp_site
}
return render(request, "datavis/site_view.html", context)
return None

View File

@ -0,0 +1,5 @@
[client]
database = SouthwestChickenTest
user =
password =
default-character-set = utf8mb4

View File

@ -75,10 +75,20 @@ WSGI_APPLICATION = 'roboteyes.wsgi.application'
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
#'default': {
# 'ENGINE': 'django.db.backends.sqlite3',
# 'NAME': BASE_DIR / 'db.sqlite3',
#}
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
"ENGINE": "django.db.backends.mysql",
"OPTIONS": {
#"read_default_file": BASE_DIR.as_posix() + "mysql.cnf",
"read_default_file": "/Users/josh/Documents/ForgejoJed/RobotHands/frontend/mysql.cnf",
},
"HOST": "100.86.33.40",
"PORT": 32768,
}
}