Merge branch '60-update-planner-as-sequences-are-shot' into 'devel'

Resolve "Update planner as sequences are shot"

Closes #60

See merge request wgp/dougal/software!12
This commit is contained in:
D. Berge
2021-06-21 14:52:11 +00:00
4 changed files with 260 additions and 131 deletions

View File

@@ -1816,7 +1816,7 @@ ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
--
ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
ADD CONSTRAINT planned_lines_pkey PRIMARY KEY (sequence);
ADD CONSTRAINT planned_lines_pkey PRIMARY KEY (sequence) DEFERRABLE;
--
@@ -1940,7 +1940,7 @@ CREATE TRIGGER final_shots_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPL
-- Name: planned_lines planned_lines_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE TRIGGER planned_lines_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.planned_lines FOR EACH STATEMENT EXECUTE FUNCTION public.notify('planned_lines');
CREATE TRIGGER planned_lines_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.planned_lines FOR EACH ROW EXECUTE FUNCTION public.notify('planned_lines');
--

View File

@@ -0,0 +1,33 @@
-- Upgrade the database from commit 3d70a460 to 0983abac.
--
-- NOTE: This upgrade must be applied to every schema in the database.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This:
--
-- * makes the primary key on planned_lines deferrable; and
-- * changes the planned_lines trigger from statement to row.
--
-- To apply, run as the dougal user, for every schema in the database:
--
-- psql <<EOF
-- SET search_path TO survey_*,public;
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
BEGIN;
ALTER TABLE planned_lines DROP CONSTRAINT planned_lines_pkey;
ALTER TABLE planned_lines ADD CONSTRAINT planned_lines_pkey PRIMARY KEY (sequence) DEFERRABLE;
DROP TRIGGER planned_lines_tg ON planned_lines;
CREATE TRIGGER planned_lines_tg AFTER INSERT OR DELETE OR UPDATE ON planned_lines FOR EACH ROW EXECUTE FUNCTION public.notify('planned_lines');
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -264,7 +264,7 @@ import suncalc from 'suncalc';
import { mapActions, mapGetters } from 'vuex';
export default {
name: "LineList",
name: "Plan",
components: {
},
@@ -369,35 +369,37 @@ export default {
if (oldVal.value === null) oldVal.value = "";
if (item) {
if (item[oldVal.key] != oldVal.value) {
if (oldVal.key == "lagAfter") {
// We need to shift the times for every subsequent sequence
const delta = oldVal.value*60*1000 - this.lagAfter(item);
await this.shiftTimesAfter(item, delta);
// Convert from minutes to seconds
oldVal.value *= 60;
} else if (oldVal.key == "speed") {
const v = oldVal.value*(1.852/3.6)/1000; // m/s
const ts1 = new Date(item.ts0.valueOf() + item.length / v);
const delta = ts1 - item.ts1;
await this.shiftTimesAfter(item, delta);
await this.saveItem({sequence: item.sequence, key: 'ts1', value: ts1});
} else if (oldVal.key == "sequence") {
if (this.shiftAll) {
await this.shiftSequences(oldVal.value-item.sequence);
} else {
await this.shiftSequence(item, oldVal.value);
// Convert knots to metres per second
oldVal.value = oldVal.value*(1.852/3.6);
}
} else if (item[oldVal.key] != oldVal.value) {
if (await this.saveItem(oldVal)) {
item[oldVal.key] = oldVal.value;
} else {
this.edit = oldVal;
}
}
}
}
},
async serverEvent (event) {
if (event.channel == "planned_lines" && event.payload.pid == this.$route.params.project) {
// Ignore non-ops
/*
if (event.payload.old === null && event.payload.new === null) {
return;
}
*/
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
@@ -583,91 +585,6 @@ export default {
await this.getPlannedLines();
},
async shiftSequences(delta) {
const lines = delta < 0
? this.items
: [...this.items].reverse(); // We go backwards so as to avoid conflicts.
for (const line of lines) {
const sequence = line.sequence+delta;
const url = `/project/${this.$route.params.project}/plan/${line.sequence}`;
const init = {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: {sequence, name: null} // Setting name to null causes it to be regenerated
}
await this.api([url, init]);
}
},
async shiftSequence (item, newSequence) {
if (item.sequence == newSequence) {
// Nothing to do
return;
}
const conflict = this.items.find(i => i.sequence == newSequence)
if (conflict) {
this.showSnack([`Sequence ${newSequence} already exists`, "error"]);
} else {
// Cannot do this check at the moment as we would have to load the list of sequences.
// TODO We will do this after refactoring.
/*
if (this.sequences.find(i => i.sequence == newSequence)) {
this.showSnack([`Sequence ${newSequence} conflicts with a line that's already been acquired`, "warning"]);
}
*/
const url = `/project/${this.$route.params.project}/plan/${item.sequence}`;
const init = {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: {
sequence: newSequence,
name: null
} // Setting name to null causes it to be regenerated
}
await this.api([url, init]);
}
},
async shiftTimesAfter(item, delta) {
const pos = this.items.indexOf(item)+1;
if (pos != 0) {
const modifiedLines = this.items.slice(pos);
if (modifiedLines.length) {
modifiedLines.reverse();
for (const line of modifiedLines) {
const ts0 = new Date(line.ts0.valueOf() + delta);
const ts1 = new Date(line.ts1.valueOf() + delta);
const url = `/project/${this.$route.params.project}/plan/${line.sequence}`;
const init = {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: {ts1, ts0}
}
await this.api([url, init]);
}
}
} else {
console.warn("Item", item, "not found");
}
},
editLagAfter (item) {
const pos = this.items.indexOf(item)+1;
if (pos != 0) {
if (pos < this.items.length) {
// Not last item
this.editedItems = this.items.slice(pos);
} else {
}
} else {
console.warn("Item", item, "not found");
}
},
editItem (item, key, value) {
this.edit = {
sequence: item.sequence,

View File

@@ -4,41 +4,220 @@ const { getLineName } = require('./lib');
async function patch (projectId, sequence, payload, opts = {}) {
const client = await setSurvey(projectId);
sequence = Number(sequence);
/*
* Takes a Date object and returns the epoch
* in seconds
*/
function epoch (ts) {
return Number(ts)/1000;
}
/*
* Shift sequence ts0, ts1 by dt0, dt1 respectively
* for only one sequence
*/
async function shiftSequence (sequence, dt0, dt1) {
const text = `
UPDATE planned_lines
SET ts0 = ts0 + make_interval(secs => $2), ts1 = ts1 + make_interval(secs => $3)
WHERE sequence = $1
`;
return await client.query(text, [sequence, dt0, dt1]);
}
/*
* Shift sequence ts0, ts1 by dt0, dt1 respectively
* for all sequences >= sequence
*/
async function shiftSequences (sequence, dt0, dt1) {
const text = `
UPDATE planned_lines
SET ts0 = ts0 + make_interval(secs => $2), ts1 = ts1 + make_interval(secs => $3)
WHERE sequence >= $1
`;
return await client.query(text, [sequence, dt0, dt1]);
}
try {
transaction.begin(client);
let deltatime;
const r0 = await client.query("SELECT * FROM planned_lines_summary WHERE sequence >= $1 ORDER BY sequence ASC LIMIT 2;", [sequence]);
const seq = (r0?.rows || [])[0];
if (!seq || seq?.sequence != sequence) {
throw {status: 400, message: `Sequence ${sequence} does not exist`};
}
const seq1 = r0.rows[1];
const speed = seq.length/(epoch(seq.ts1)-epoch(seq.ts0)); // m/s
if ("ts0" in payload || "ts1" in payload) {
/*
* Change in start or end times
*/
deltatime = "ts0" in payload
? (epoch(new Date(payload.ts0)) - epoch(seq.ts0))
: (epoch(new Date(payload.ts1)) - epoch(seq.ts1));
// Now shift all sequences >= this one by deltatime
await shiftSequences(sequence, deltatime, deltatime);
} else if ("speed" in payload) {
/*
* Change in acquisition speed (m/s)
*/
// Check that speed is sensible
if (payload.speed < 0.1) {
throw {status: 400, message: "Speed must be at least 0.1 m/s"};
}
deltatime = epoch(seq.ts0) + (seq.length/payload.speed) - epoch(seq.ts1);
// Fix seq.ts0, shift set.ts1 += deltatime, plus all sequences > this one
await shiftSequence(sequence, 0, deltatime);
await shiftSequences(sequence+1, deltatime, deltatime);
} else if ("fsp" in payload) {
/*
* Change of FSP
*/
// Keep ts1, adjust fsp and ts0 according to speed
// ts0' = (shot_distance * delta_shots / speed) + ts0
const sign = Math.sign(seq.lsp-seq.fsp);
const ts0 = (sign * (seq.length/seq.num_points) * (payload.fsp-seq.fsp) / speed) + epoch(seq.ts0);
const text = `
UPDATE planned_lines
SET fsp = $2, ts0 = $3
WHERE sequence = $1;
`;
await client.query(text, [sequence, payload.fsp, new Date(ts0*1000)]);
} else if ("lsp" in payload) {
/*
* Change of LSP
*/
// Keep ts0, adjust lsp and ts1 according to speed
// Calculate deltatime from ts1'-ts1
// Shift all sequences > this one by deltatime
// deltatime = (shot_distance * delta_shots / speed)
// ts1' = deltatime + ts1
const sign = Math.sign(seq.lsp-seq.fsp);
deltatime = (sign * (seq.length/seq.num_points) * (payload.lsp-seq.lsp) / speed);
const ts1 = deltatime + epoch(seq.ts1);
const text = `
UPDATE planned_lines
SET lsp = $2, ts1 = $3
WHERE sequence = $1;
`;
await client.query(text, [sequence, payload.lsp, new Date(ts1*1000)]);
shiftSequences(sequence+1, deltatime, deltatime);
} else if ("lagAfter" in payload && seq1) {
/*
* Change of line change time
*/
// Check that the value is sensible
if (payload.lagAfter < 0) {
throw {status: 400, message: "Line change time cannot be negative"};
}
// Calculate deltatime from next sequence's ts0'-ts0
// Shift all sequences > this one by deltatime
const ts0 = epoch(seq.ts1) + payload.lagAfter; // lagAfter is in seconds
deltatime = ts0 - epoch(seq1.ts0);
shiftSequences(sequence+1, deltatime, deltatime);
} else if ("sequence" in payload) {
/*
* Renumbering / reshuffling of sequences
*/
// NOTE: This does not enforce consecutive sequences, because sometimes
// there is a need for those (don't ask).
// Renumber or reorder sequences
const r1 = await client.query("SELECT sequence FROM planned_lines ORDER BY sequence;");
const sequences = (r1?.rows||[]).map(i => i.sequence);
const index = sequences.indexOf(payload.sequence);
if (index != -1) {
// Make space by shifting all sequence numbers >= payload.sequence by 1
const text = `
UPDATE planned_lines
SET sequence = sequence + 1
WHERE sequence >= $1;
`;
await client.query("SET CONSTRAINTS planned_lines_pkey DEFERRED;");
await client.query(text, [payload.sequence]);
// And now we need to rename all affected lines
const r2 = await client.query("SELECT * FROM planned_lines WHERE sequence > $1 ORDER BY sequence;", [payload.sequence]);
for (let row in r2.rows) {
const name = await getLineName(client, projectId, row);
await client.query("UPDATE planned_lines SET name = $2 WHERE sequence = $1", [row.sequence, name]);
}
}
// Now update just this sequence
const text = `
UPDATE planned_lines
SET sequence = $2
WHERE sequence = $1;
`;
await client.query(text, [sequence, payload.sequence]);
// And rename
const r3 = await client.query("SELECT * FROM planned_lines WHERE sequence = $1 ORDER BY sequence;", [payload.sequence]);
const name = await getLineName(client, projectId, r3.rows[0]);
await client.query("UPDATE planned_lines SET name = $2 WHERE sequence = $1", [payload.sequence, name]);
} else if (["name", "remarks", "meta"].some(i => i in payload)) {
/*
* Change in various other attributes that do not affect
* other sequences
*/
// NOTE Magic! If name is empty, we generate one.
// Can be used for going back to a default name after it's been
// changed manually.
if (payload.name === "") {
payload.name = await getLineName(client, projectId, r0.rows[0]);
}
// Change the relevant attribute
const text = `
UPDATE planned_lines
SET
sequence = COALESCE($2, sequence),
fsp = COALESCE($3, fsp),
lsp = COALESCE($4, lsp),
ts0 = COALESCE($5, ts0),
ts1 = COALESCE($6, ts1),
name = COALESCE($7, name),
remarks = COALESCE($8, remarks),
meta = COALESCE($9, meta)
name = COALESCE($2, name),
remarks = COALESCE($3, remarks),
meta = COALESCE($4, meta)
WHERE sequence = $1;
`
`;
await client.query(text, [sequence, payload.name, payload.remarks, payload.meta]);
const p = payload; // For short
const values = [ sequence, p.sequence, p.fsp, p.lsp, p.ts0, p.ts1, p.name, p.remarks, p.meta ];
await client.query(text, values);
// Magic if the name is (strictly) null or empty, we generate a new one
if (p.name === null || p.name === "") {
const text = "SELECT * FROM planned_lines WHERE sequence = $1;";
const res = await client.query(text, [p.sequence||sequence]);
const row = res.rows[0];
const name = await getLineName(client, projectId, row);
await client.query("UPDATE planned_lines SET name = $2 WHERE sequence = $1", [p.sequence||sequence, name]);
} else {
throw { status: 400, message: "Bad request"};
}
transaction.commit(client);
} catch (err) {
transaction.rollback(client);
if (err.code == 23503) {
if (err.constraint == "planned_lines_line_fsp_class_fkey" || err.constraint == "planned_lines_line_lsp_class_fkey") {
throw {status: 400, message: "Attempt to shoot a non-existent shotpoint"};
}
} else {
throw err;
}
} finally {
client.release();
}