keepset 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,7 @@ The catalogue itself is a manifest (`catalogue/manifest.json`: id, version, repo
63
63
 
64
64
  ## Categories and normalisation targets
65
65
 
66
- A category names a normalisation target: the shape every entry in that category resolves to, whatever its native shape. `graph/v1` is `{ meta, nodes, edges }` with unique node ids and edges whose endpoints name nodes; its entries are graph datasets taken from upstream collections such as SuiteSparse, Netzschleuder, SNAP, KONECT and OGB. `or/v1` is `{ meta, nodes }`, an operations-research instance as the nodes it offers to visit, each flat with optional coordinates, prize, prize probability, duration, travel time, skill, territory, lifecycle status and times, and deadline, read from tabular sources by the `csv-table` parser (a delimited file, a member of an archive, a sheet of an Excel workbook, or an open data portal read a page at a time) and from ArcGIS feature layers by the `geojson` parser. Its entries come from a survey of open operations-research datasets, imported with a curation that records, for each source, what its download gives and how its columns map onto the node; a dataset whose source has no usable table is imported without a recipe and says why. A new category starts by defining its target schema in `src/targets/` and registering it in the category registry; an entry cannot name a category that does not exist. The statically generated site renders every entry with schema.org Dataset JSON-LD, so the catalogue surfaces in the dataset discovery layers.
66
+ A category names a normalisation target: the shape every entry in that category resolves to, whatever its native shape. `graph/v1` is `{ meta, nodes, edges }` with unique node ids and edges whose endpoints name nodes; its entries are graph datasets taken from upstream collections such as SuiteSparse, Netzschleuder, SNAP, KONECT and OGB. `or/v1` is `{ meta, nodes }`, an operations-research instance as the nodes it offers to visit, each flat with optional coordinates, prize, prize probability, duration, travel time, skill, territory, lifecycle status and times, and deadline, read from tabular sources by the `csv-table` parser (a delimited file, a member of an archive, a sheet of an Excel workbook, or an open data portal read a page at a time) and from ArcGIS feature layers by the `geojson` parser. Its entries come from a survey of open operations-research datasets, imported with a curation that records, for each source, what its download gives and how its columns map onto the node; a dataset whose source has no usable table is imported without a recipe and says why. `or-set/v1` is `{ meta, instances }`, a collection of operations-research instances such as a benchmark suite, each instance an `or/v1` document under an id of its own (for a collection read from an archive, the path of the member it came from): a recipe selects every matching archive member with `members`, the parser reads each one, and a member that does not parse fails the fetch and is named. A new category starts by defining its target schema in `src/targets/` and registering it in the category registry; an entry cannot name a category that does not exist. The statically generated site renders every entry with schema.org Dataset JSON-LD, so the catalogue surfaces in the dataset discovery layers.
67
67
 
68
68
  ## Licensing
69
69
 
@@ -113,14 +113,16 @@ const JsonScalarSchema = z.union([
113
113
  * Reports each node whose id an earlier node already has, as an issue on that node's id.
114
114
  * @param nodes - The nodes of a target document, in order.
115
115
  * @param ctx - The refinement context to report to.
116
+ * @param prefix - The path of the object holding the `nodes` array, for a document that nests nodes inside another.
116
117
  * @returns The set of ids the nodes carry.
117
118
  */
118
- function addDuplicateIdIssues(nodes, ctx) {
119
+ function addDuplicateIdIssues(nodes, ctx, prefix = []) {
119
120
  const ids = /* @__PURE__ */ new Set();
120
121
  for (const [index, node] of nodes.entries()) {
121
122
  if (ids.has(node.id)) ctx.addIssue({
122
123
  code: "custom",
123
124
  path: [
125
+ ...prefix,
124
126
  "nodes",
125
127
  index,
126
128
  "id"
@@ -202,22 +204,65 @@ const OrV1Schema = define(z.object({
202
204
  addDuplicateIdIssues(instance.nodes, ctx);
203
205
  }));
204
206
  //#endregion
207
+ //#region src/targets/or-set-v1.ts
208
+ /** One instance of a set: an or/v1 instance, `{ meta, nodes }`, with the id that names it within the set (for a collection read from an archive, the member's path). */
209
+ const OrInstanceSchema = z.object({
210
+ id: z.string().min(1),
211
+ meta: recordOf(z.string().min(1), JsonScalarSchema),
212
+ nodes: z.array(OrNodeSchema)
213
+ }).strict();
214
+ /**
215
+ * The or-set/v1 normalisation target: `{ meta, instances }`, a collection of operations-research instances such as a benchmark suite, each instance an or/v1 document (`{ meta, nodes }`) under an id of its own. Beyond the shapes it checks that instance ids are unique in the set and node ids unique within each instance. A node id need not be unique across instances, because every instance numbers its own nodes.
216
+ */
217
+ const OrSetV1Schema = define(z.object({
218
+ meta: recordOf(z.string().min(1), JsonScalarSchema),
219
+ instances: z.array(OrInstanceSchema)
220
+ }).strict().superRefine((set, ctx) => {
221
+ const ids = /* @__PURE__ */ new Set();
222
+ for (const [index, instance] of set.instances.entries()) {
223
+ if (ids.has(instance.id)) ctx.addIssue({
224
+ code: "custom",
225
+ path: [
226
+ "instances",
227
+ index,
228
+ "id"
229
+ ],
230
+ message: `duplicate instance id ${JSON.stringify(instance.id)}`
231
+ });
232
+ ids.add(instance.id);
233
+ addDuplicateIdIssues(instance.nodes, ctx, ["instances", index]);
234
+ }
235
+ }));
236
+ //#endregion
205
237
  //#region src/categories.ts
206
238
  /** Every category id the registry knows. A category id is `<kind>/v<version>`: the kind of data, and the version of its normalisation target, so a breaking change to a target is a new category rather than an edit to an old one. */
207
- const CategoryIdSchema = z.enum(["graph/v1", "or/v1"]);
239
+ const CategoryIdSchema = z.enum([
240
+ "graph/v1",
241
+ "or/v1",
242
+ "or-set/v1"
243
+ ]);
208
244
  /** The registry. Adding a category is adding its id to `CategoryIdSchema` and its entry here, with its target schema beside graph/v1's in src/targets/: the mapped type makes the compiler reject a registry that misses an id or has one too many. */
209
245
  const registry = {
210
246
  "graph/v1": {
211
247
  id: "graph/v1",
212
248
  description: "A graph as { meta, nodes, edges }: a flat record of scalar metadata, nodes carrying at least an id, and edges carrying at least source and target node ids, with optional node and edge types.",
249
+ shape: "document",
213
250
  target: GraphV1Schema,
214
251
  counted: ["nodes", "edges"]
215
252
  },
216
253
  "or/v1": {
217
254
  id: "or/v1",
218
255
  description: "An operations-research instance as { meta, nodes }: a flat record of scalar metadata and nodes carrying at least an id, with optional coordinates, prize, prize probability, duration, travel time, skill, territory, lifecycle status and times, and deadline.",
256
+ shape: "document",
219
257
  target: OrV1Schema,
220
258
  counted: ["nodes"]
259
+ },
260
+ "or-set/v1": {
261
+ id: "or-set/v1",
262
+ description: "A collection of operations-research instances as { meta, instances }: a flat record of scalar metadata and instances, each an or/v1 document ({ meta, nodes }) under an id of its own, read from every matching member of an archive.",
263
+ shape: "collection",
264
+ target: OrSetV1Schema,
265
+ counted: ["instances"]
221
266
  }
222
267
  };
223
268
  function getCategory(id) {
@@ -410,22 +455,26 @@ const CitationSchema = z.object({
410
455
  const RelativePathSchema = z.string().refine((path) => path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."), { message: "must be a relative path of non-empty segments with no '.' or '..'" });
411
456
  /** Which file of an archive to read: a member path, matched against a member's whole path or its trailing segments (so `net.gml` finds `net/net.gml`), or the member whose name ends with one of a list of extensions (a format can have several, such as Pajek's `.net` and `.paj`). Either must match exactly one member when the recipe runs; macOS resource-fork members (`__MACOSX/` and `._` files) never match. */
412
457
  const ArchiveMemberSchema = z.union([z.object({ path: RelativePathSchema }).strict(), z.object({ extensions: z.array(z.string().regex(/^\.[A-Za-z0-9][A-Za-z0-9.]*$/, { message: "must be a file extension such as .mtx" })).min(1) }).strict()]);
413
- /** How the downloaded bytes unpack into the one file the parser reads. A workbook (`xlsx`) is read as the CSV of its named sheet, with dates as ISO 8601 text, for the `csv-table` parser. */
414
- const ArchiveSchema = z.discriminatedUnion("kind", [
458
+ /** The archive kinds that hold several files, from which a recipe selects one (`member`) or every one a selector matches (`members`). */
459
+ const FileArchiveKindSchema = z.enum([
460
+ "zip",
461
+ "tar",
462
+ "tar.gz"
463
+ ]);
464
+ /** An archive a collection is read from: every file a selector matches, at least one. */
465
+ const CollectionArchiveSchema = z.object({
466
+ kind: FileArchiveKindSchema,
467
+ members: ArchiveMemberSchema
468
+ }).strict();
469
+ /** How the downloaded bytes unpack into the file the parser reads, or for a collection the files it reads one by one. A `members` selector gives every member it matches, at least one, in path order. A workbook (`xlsx`) is read as the CSV of its named sheet, with dates as ISO 8601 text, for the `csv-table` parser. */
470
+ const ArchiveSchema = z.union([
415
471
  z.object({ kind: z.literal("none") }).strict(),
416
472
  z.object({ kind: z.literal("gzip") }).strict(),
417
473
  z.object({
418
- kind: z.literal("zip"),
419
- member: ArchiveMemberSchema
420
- }).strict(),
421
- z.object({
422
- kind: z.literal("tar"),
423
- member: ArchiveMemberSchema
424
- }).strict(),
425
- z.object({
426
- kind: z.literal("tar.gz"),
474
+ kind: FileArchiveKindSchema,
427
475
  member: ArchiveMemberSchema
428
476
  }).strict(),
477
+ CollectionArchiveSchema,
429
478
  z.object({
430
479
  kind: z.literal("xlsx"),
431
480
  sheet: z.string().min(1)
@@ -468,6 +517,10 @@ const ParserSchema = z.discriminatedUnion("format", [
468
517
  format: z.literal("pajek"),
469
518
  encoding: EncodingSchema
470
519
  }).strict(),
520
+ z.object({
521
+ format: z.literal("solomon"),
522
+ encoding: EncodingSchema
523
+ }).strict(),
471
524
  z.object({
472
525
  format: z.literal("csv-table"),
473
526
  encoding: EncodingSchema,
@@ -516,7 +569,7 @@ const ParserSchema = z.discriminatedUnion("format", [
516
569
  path: ["dateColumns"]
517
570
  })
518
571
  ]);
519
- /** Where a source is a paged query and not one file. An ArcGIS feature layer returns a limited number of features per request: the source URL is the layer's, the download reads every page in `orderBy` order and the parser reads the one GeoJSON collection they make. A Socrata resource endpoint (`/resource/<id>.csv`) is read `pageSize` rows at a time in the order of its row ids, and a CKAN datastore dump (`/datastore/dump/<id>`) `pageSize` rows at a time, each page a CSV with its own header; the download joins them into the one CSV the parser reads, so no single response has to survive a long transfer. */
572
+ /** Where a source is a paged query and not one file. An ArcGIS feature layer returns a limited number of features per request: the source URL is the layer's, the download reads every page in `orderBy` order and the parser reads the one GeoJSON collection they make. A Socrata resource endpoint (`/resource/<id>.csv`) is read `pageSize` rows at a time in the order of its row ids, a CKAN datastore dump (`/datastore/dump/<id>`) and an OpenFEMA entity (`/api/open/v<n>/<Entity>`, with any `$select` or `$filter` in the source URL) `pageSize` rows at a time, each page a CSV with its own header; the download joins them into the one CSV the parser reads, so no single response has to survive a long transfer. */
520
573
  const PagingSchema = z.discriminatedUnion("kind", [
521
574
  z.object({
522
575
  kind: z.literal("arcgis-query"),
@@ -529,6 +582,10 @@ const PagingSchema = z.discriminatedUnion("kind", [
529
582
  z.object({
530
583
  kind: z.literal("ckan-datastore"),
531
584
  pageSize: z.int().min(1)
585
+ }).strict(),
586
+ z.object({
587
+ kind: z.literal("openfema"),
588
+ pageSize: z.int().min(1)
532
589
  }).strict()
533
590
  ]);
534
591
  /** How to obtain the dataset: a fetchable recipe naming the source (by index into `sources`), the archive layout and the parser, or a statement that no declarative recipe exists yet and why. */
@@ -583,6 +640,24 @@ const EntrySchema = define(z.object({
583
640
  path: ["recipe", "source"],
584
641
  message: `names source ${String(entry.recipe.source)} but the entry has ${String(entry.sources.length)}`
585
642
  });
643
+ if (entry.recipe.status === "fetchable") {
644
+ const selectsMembers = "members" in entry.recipe.archive;
645
+ const { shape } = getCategory(entry.category);
646
+ if (shape === "collection" && !selectsMembers) ctx.addIssue({
647
+ code: "custom",
648
+ path: ["recipe", "archive"],
649
+ message: `${entry.category} is a collection, so the archive must select its members with \`members\``
650
+ });
651
+ if (shape === "document" && selectsMembers) ctx.addIssue({
652
+ code: "custom",
653
+ path: [
654
+ "recipe",
655
+ "archive",
656
+ "members"
657
+ ],
658
+ message: `${entry.category} is one document, so the archive selects one file with \`member\``
659
+ });
660
+ }
586
661
  if ("target" in entry.crosswalk && entry.crosswalk.target !== entry.category) ctx.addIssue({
587
662
  code: "custom",
588
663
  path: ["crosswalk", "target"],
@@ -905,7 +980,7 @@ var catalogue_default = {
905
980
  "id": "keepset",
906
981
  "name": "keepset",
907
982
  "description": "The keepset catalogue: datasets with machine-readable recipes that fetch each one and normalise it into its category's target shape.",
908
- "version": "1.10.0",
983
+ "version": "1.12.0",
909
984
  "repository": "https://github.com/Mearman/keepset",
910
985
  "namespaceAliases": {
911
986
  "networkrepository": ["nr"],
@@ -6414,14 +6489,90 @@ var catalogue_default = {
6414
6489
  "url": "https://www.fema.gov/openfema-data-page/fima-nfip-redacted-claims-v2",
6415
6490
  "retrieved": "2026-10-19",
6416
6491
  "status": "live"
6492
+ }, {
6493
+ "url": "https://www.fema.gov/api/open/v3/NfipClaims?$select=id,openDate,latitude,longitude,amountPaidOnBuildingClaim",
6494
+ "retrieved": "2026-10-19",
6495
+ "status": "live"
6417
6496
  }],
6418
- "licence": { "kind": "unrecorded" },
6497
+ "licence": {
6498
+ "kind": "custom",
6499
+ "name": "OpenFEMA Terms and Conditions",
6500
+ "url": "https://www.fema.gov/about/openfema/terms-conditions"
6501
+ },
6419
6502
  "citations": [],
6420
6503
  "recipe": {
6421
- "status": "not-fetchable",
6422
- "reason": "The OpenFEMA v3 API pages claims anonymously as CSV, but the table has millions of rows whose locations are rounded to a tenth of a degree, the terms of use limit it to statistical use without reidentification, and keepset has no recipe paging for this API."
6504
+ "status": "fetchable",
6505
+ "source": 1,
6506
+ "paging": {
6507
+ "kind": "openfema",
6508
+ "pageSize": 5e4
6509
+ },
6510
+ "archive": { "kind": "none" },
6511
+ "parser": {
6512
+ "format": "csv-table",
6513
+ "encoding": "utf-8",
6514
+ "delimiter": "comma",
6515
+ "skipLines": 0,
6516
+ "idColumn": "id",
6517
+ "numericColumns": [
6518
+ {
6519
+ "column": "latitude",
6520
+ "factor": 1
6521
+ },
6522
+ {
6523
+ "column": "longitude",
6524
+ "factor": 1
6525
+ },
6526
+ {
6527
+ "column": "amountPaidOnBuildingClaim",
6528
+ "factor": 1
6529
+ }
6530
+ ],
6531
+ "columns": [
6532
+ "latitude",
6533
+ "longitude",
6534
+ "openDate",
6535
+ "amountPaidOnBuildingClaim"
6536
+ ]
6537
+ }
6538
+ },
6539
+ "crosswalk": {
6540
+ "source": "csv-table",
6541
+ "target": "or/v1",
6542
+ "rules": [
6543
+ {
6544
+ "op": "move",
6545
+ "from": "meta",
6546
+ "to": "meta"
6547
+ },
6548
+ {
6549
+ "op": "move",
6550
+ "from": "nodes[].id",
6551
+ "to": "nodes[].id"
6552
+ },
6553
+ {
6554
+ "op": "move",
6555
+ "from": "nodes[].latitude",
6556
+ "to": "nodes[].lat"
6557
+ },
6558
+ {
6559
+ "op": "move",
6560
+ "from": "nodes[].longitude",
6561
+ "to": "nodes[].lon"
6562
+ },
6563
+ {
6564
+ "op": "move",
6565
+ "from": "nodes[].openDate",
6566
+ "to": "nodes[].opened"
6567
+ },
6568
+ {
6569
+ "op": "move",
6570
+ "from": "nodes[].amountPaidOnBuildingClaim",
6571
+ "to": "nodes[].prize"
6572
+ }
6573
+ ],
6574
+ "oneWay": []
6423
6575
  },
6424
- "crosswalk": { "file": "crosswalks/or-v1/nodes.json" },
6425
6576
  "expectedCounts": {},
6426
6577
  "properties": { "surveyId": "or-122" }
6427
6578
  },
@@ -7025,7 +7176,7 @@ var catalogue_default = {
7025
7176
  "duration",
7026
7177
  "travelTime"
7027
7178
  ],
7028
- "category": "or/v1",
7179
+ "category": "or-set/v1",
7029
7180
  "sources": [{
7030
7181
  "url": "https://github.com/psxjpc/MOVRPTW-Generator",
7031
7182
  "retrieved": "2026-10-19",
@@ -7041,12 +7192,95 @@ var catalogue_default = {
7041
7192
  },
7042
7193
  "citations": [],
7043
7194
  "recipe": {
7044
- "status": "not-fetchable",
7045
- "reason": "The download is a collection of Solomon style instance files and distance matrices, and a collection is more than one or/v1 document, so no recipe reads it."
7195
+ "status": "fetchable",
7196
+ "source": 1,
7197
+ "archive": {
7198
+ "kind": "zip",
7199
+ "members": { "extensions": [
7200
+ ".tw0Specs.dat",
7201
+ ".tw1Specs.dat",
7202
+ ".tw2Specs.dat",
7203
+ ".tw3Specs.dat",
7204
+ ".tw4Specs.dat"
7205
+ ] }
7206
+ },
7207
+ "parser": {
7208
+ "format": "solomon",
7209
+ "encoding": "utf-8"
7210
+ }
7046
7211
  },
7047
- "crosswalk": { "file": "crosswalks/or-v1/nodes.json" },
7048
- "expectedCounts": {},
7049
- "properties": { "surveyId": "or-9" }
7212
+ "crosswalk": {
7213
+ "source": "solomon",
7214
+ "target": "or-set/v1",
7215
+ "rules": [
7216
+ {
7217
+ "op": "move",
7218
+ "from": "meta",
7219
+ "to": "meta"
7220
+ },
7221
+ {
7222
+ "op": "move",
7223
+ "from": "members[].id",
7224
+ "to": "instances[].id"
7225
+ },
7226
+ {
7227
+ "op": "move",
7228
+ "from": "members[].meta.name",
7229
+ "to": "instances[].meta.name"
7230
+ },
7231
+ {
7232
+ "op": "move",
7233
+ "from": "members[].meta.vehicles",
7234
+ "to": "instances[].meta.vehicles"
7235
+ },
7236
+ {
7237
+ "op": "move",
7238
+ "from": "members[].meta.capacity",
7239
+ "to": "instances[].meta.capacity"
7240
+ },
7241
+ {
7242
+ "op": "move",
7243
+ "from": "members[].nodes[].id",
7244
+ "to": "instances[].nodes[].id"
7245
+ },
7246
+ {
7247
+ "op": "move",
7248
+ "from": "members[].nodes[].due",
7249
+ "to": "instances[].nodes[].deadline"
7250
+ },
7251
+ {
7252
+ "op": "move",
7253
+ "from": "members[].nodes[].service",
7254
+ "to": "instances[].nodes[].duration"
7255
+ },
7256
+ {
7257
+ "op": "move",
7258
+ "from": "members[].nodes[].x",
7259
+ "to": "instances[].nodes[].lat"
7260
+ },
7261
+ {
7262
+ "op": "move",
7263
+ "from": "members[].nodes[].y",
7264
+ "to": "instances[].nodes[].lon"
7265
+ },
7266
+ {
7267
+ "op": "move",
7268
+ "from": "members[].nodes[].demand",
7269
+ "to": "instances[].nodes[].demand"
7270
+ },
7271
+ {
7272
+ "op": "move",
7273
+ "from": "members[].nodes[].ready",
7274
+ "to": "instances[].nodes[].ready"
7275
+ }
7276
+ ],
7277
+ "oneWay": []
7278
+ },
7279
+ "expectedCounts": { "instances": 45 },
7280
+ "properties": {
7281
+ "surveyId": "or-9",
7282
+ "timeUnit": "seconds"
7283
+ }
7050
7284
  },
7051
7285
  {
7052
7286
  "id": "github-zhangchenhaoseu-jd-cold-chain-nodes",
@@ -8506,21 +8740,102 @@ var catalogue_default = {
8506
8740
  "duration",
8507
8741
  "travelTime"
8508
8742
  ],
8509
- "category": "or/v1",
8743
+ "category": "or-set/v1",
8510
8744
  "sources": [{
8511
8745
  "url": "https://neo.lcc.uma.es/vrp/vrp-instances/capacitated-vrp-with-time-windows-instances/",
8512
8746
  "retrieved": "2026-10-19",
8513
8747
  "status": "live"
8748
+ }, {
8749
+ "url": "https://neo.lcc.uma.es/vrp/wp-content/data/instances/solomon/solomon_100.zip",
8750
+ "retrieved": "2026-10-19",
8751
+ "status": "live"
8514
8752
  }],
8515
8753
  "licence": { "kind": "unrecorded" },
8516
8754
  "citations": [],
8517
8755
  "recipe": {
8518
- "status": "not-fetchable",
8519
- "reason": "The download is a collection of Solomon instance files (planar coordinates, demands, service times and windows in abstract units, not seconds), and a collection is more than one or/v1 document, so no recipe reads it."
8756
+ "status": "fetchable",
8757
+ "source": 1,
8758
+ "archive": {
8759
+ "kind": "zip",
8760
+ "members": { "extensions": [".txt"] }
8761
+ },
8762
+ "parser": {
8763
+ "format": "solomon",
8764
+ "encoding": "utf-8"
8765
+ }
8520
8766
  },
8521
- "crosswalk": { "file": "crosswalks/or-v1/nodes.json" },
8522
- "expectedCounts": {},
8523
- "properties": { "surveyId": "or-5" }
8767
+ "crosswalk": {
8768
+ "source": "solomon",
8769
+ "target": "or-set/v1",
8770
+ "rules": [
8771
+ {
8772
+ "op": "move",
8773
+ "from": "meta",
8774
+ "to": "meta"
8775
+ },
8776
+ {
8777
+ "op": "move",
8778
+ "from": "members[].id",
8779
+ "to": "instances[].id"
8780
+ },
8781
+ {
8782
+ "op": "move",
8783
+ "from": "members[].meta.name",
8784
+ "to": "instances[].meta.name"
8785
+ },
8786
+ {
8787
+ "op": "move",
8788
+ "from": "members[].meta.vehicles",
8789
+ "to": "instances[].meta.vehicles"
8790
+ },
8791
+ {
8792
+ "op": "move",
8793
+ "from": "members[].meta.capacity",
8794
+ "to": "instances[].meta.capacity"
8795
+ },
8796
+ {
8797
+ "op": "move",
8798
+ "from": "members[].nodes[].id",
8799
+ "to": "instances[].nodes[].id"
8800
+ },
8801
+ {
8802
+ "op": "move",
8803
+ "from": "members[].nodes[].due",
8804
+ "to": "instances[].nodes[].deadline"
8805
+ },
8806
+ {
8807
+ "op": "move",
8808
+ "from": "members[].nodes[].x",
8809
+ "to": "instances[].nodes[].x"
8810
+ },
8811
+ {
8812
+ "op": "move",
8813
+ "from": "members[].nodes[].y",
8814
+ "to": "instances[].nodes[].y"
8815
+ },
8816
+ {
8817
+ "op": "move",
8818
+ "from": "members[].nodes[].demand",
8819
+ "to": "instances[].nodes[].demand"
8820
+ },
8821
+ {
8822
+ "op": "move",
8823
+ "from": "members[].nodes[].ready",
8824
+ "to": "instances[].nodes[].ready"
8825
+ },
8826
+ {
8827
+ "op": "move",
8828
+ "from": "members[].nodes[].service",
8829
+ "to": "instances[].nodes[].service"
8830
+ }
8831
+ ],
8832
+ "oneWay": []
8833
+ },
8834
+ "expectedCounts": { "instances": 56 },
8835
+ "properties": {
8836
+ "surveyId": "or-5",
8837
+ "timeUnit": "abstract units"
8838
+ }
8524
8839
  },
8525
8840
  {
8526
8841
  "id": "leeds-fixed-penalty-notices",